A notification center shows recent messages and derives its unread badge from the messages that are still unread. Build the familiar bell and panel while keeping the notification array as the single source of truth. React should recompute the badge during render instead of storing a second count that can drift.
Implement the default App component in App.tsx. It receives no props and owns an array of { id: number; text: string; read: boolean } notifications plus an open boolean.
3.New comment on your post marks only that row as read. Its dot and highlight disappear, and the badge changes to 2.× on Build #42 passed removes that row without also marking it read. Because it was unread, the derived badge decreases by one.Mark all read clears every dot and hides the badge when the unread count reaches 0.App.tsx contains a static version of the required first frame, and styles.css contains the complete visual design. Replace the repeated mock-up with data-driven JSX and implement the interactions without changing the copy or class names.
items.filter((item) => !item.read).length during render; never store the count in state.map to mark notifications read and filter to dismiss one so React receives a new array.× is inside a clickable row, so its handler must call event.stopPropagation().Keep the notifications in state and treat the unread badge as a value you compute from them — never a second number you store and babysit.
A notification center holds a list of items, each read or unread. The bell shows how many are unread; the panel lets you read them (which clears their dot), throw them away, or clear everything at once. The temptation is to keep a count next to the list and adjust it on every action. That count is the bug: it's the same information as the list, stored twice, and the two drift apart the first time you forget to update one.
There is exactly one source of truth — the items array. Everything else is derived from it: the badge is items.filter((n) => !n.read).length, the dot on a row is !n.read, the panel is a map over items. Change the array and React re-renders; the badge recomputes for free.
The obvious version stores the count alongside the list and adjusts it by hand:
const [items, setItems] = useState(INITIAL);
const [unread, setUnread] = useState(3); // second source of truth
function markRead(id) {
setItems(items.map((n) => (n.id === id ? { ...n, read: true } : n)));
setUnread(unread - 1); // must remember to do this, every time, everywhere
}
It works until it doesn't. Mark an already-read item and unread goes negative. Dismiss an unread row and you have to remember to decrement here too. markAll has to reset it to 0. Every action now has two jobs, and the count is only ever as correct as your discipline. Derive it instead and there's nothing to keep in sync.
import { useState } from 'react';
import './styles.css';
type Note = { id: number; text: string; read: boolean };
const INITIAL: Note[] = [
{ id: 1, text: 'New comment on your post', read: false },
{ id: 2, text: 'Build #42 passed', read: false },
{ id: 3, text: 'Weekly digest ready', read: false },
{ id: 4, text: 'Password changed', read: true },
];
export default function App() {
const [items, setItems] = useState<Note[]>(INITIAL);
const [open, setOpen] = useState(true);
const unread = items.filter((n) => !n.read).length; // derived, not stored
const markRead = (id: number) =>
setItems((list) => list.map((n) => (n.id === id ? { ...n, read: true } : n)));
const dismiss = (id: number) =>
setItems((list) => list.filter((n) => n.id !== id));
const markAll = () =>
setItems((list) => list.map((n) => ({ ...n, read: true })));
return (
<main className="container">
<h1>Notification Center</h1>
<div className="bell-wrap">
<button
type="button"
className="bell"
aria-label="Notifications"
onClick={() => setOpen((o) => !o)}
>
🔔
</button>
{unread > 0 && <span className="badge">{unread}</span>}
</div>
{open && (
<ul className="panel">
{items.map((n) => (
<li
key={n.id}
className={n.read ? 'note' : 'note unread'}
onClick={() => markRead(n.id)}
>
{!n.read && <span className="dot" />}
<span className="text">{n.text}</span>
<button
type="button"
className="x"
aria-label="Dismiss"
onClick={(e) => {
e.stopPropagation();
dismiss(n.id);
}}
>
×
</button>
</li>
))}
</ul>
)}
{open && (
<button type="button" className="markall" onClick={markAll}>
Mark all read
</button>
)}
</main>
);
}
The key shift from the naive version: unread is a plain const computed during render, not state. There is nothing to increment or reset, so markRead, dismiss, and markAll each do one thing — change the array — and the badge simply follows. Every update is immutable (map for read changes, filter for dismiss) so React sees a new array and re-renders.
items has three unread → unread === 3 → badge shows 3, panel lists four rows.New comment on your post: markRead(1) maps to a new array with read: true on id 1 → re-render → unread === 2 → its dot and highlight are gone, badge reads 2.Mark all read: markAll maps every item to { ...n, read: true } → unread === 0 → the unread > 0 guard is false → the badge is not rendered at all.Dismiss is the other kind of change: instead of flipping a field, it removes the item. filter returns a new array without that id, and because the panel is a map over items, the row disappears. The one trap is the click target.
The × lives inside the row, and the row has its own onClick that marks read. Without e.stopPropagation() on the button, one click bubbles up and runs both handlers — the item is marked read on its way out, which is harmless here but is the exact bug that bites when the row action is destructive.
count state next to items is duplicated truth that drifts. Derive unread from items every render.stopPropagation — the × sits inside the clickable row, so its handler must stop the event or dismiss also fires the row's markRead.n.read = true on an item already in state won't re-render. Build a new array with map, or a new object with { ...n, read: true }.Earlier divider, all from the same derived split of items.items to localStorage in an effect so dismissals survive a reload.A reducer centralizes the state transitions while the badge remains derived from the current list. The rendered structure and behavior stay identical.
import { useReducer } from 'react';
import './styles.css';
type Note = { id: number; text: string; read: boolean };
type State = { items: Note[]; open: boolean };
type Action =
| { type: 'toggle' }
| { type: 'read'; id: number }
| { type: 'dismiss'; id: number }
| { type: 'readAll' };
const initial: State = {
open: true,
items: [
{ id: 1, text: 'New comment on your post', read: false },
{ id: 2, text: 'Build #42 passed', read: false },
{ id: 3, text: 'Weekly digest ready', read: false },
{ id: 4, text: 'Password changed', read: true },
],
};
function reducer(state: State, action: Action): State {
if (action.type === 'toggle') return { ...state, open: !state.open };
if (action.type === 'read') return { ...state, items: state.items.map((note) => note.id === action.id ? { ...note, read: true } : note) };
if (action.type === 'dismiss') return { ...state, items: state.items.filter((note) => note.id !== action.id) };
return { ...state, items: state.items.map((note) => ({ ...note, read: true })) };
}
export default function App() {
const [state, dispatch] = useReducer(reducer, initial);
const unread = state.items.filter((note) => !note.read).length;
return (
<main className="container">
<h1>Notification Center</h1>
<div className="bell-wrap">
<button type="button" className="bell" aria-label="Notifications" onClick={() => dispatch({ type: 'toggle' })}>🔔</button>
{unread > 0 && <span className="badge">{unread}</span>}
</div>
{state.open && <ul className="panel">
{state.items.map((note) => <li key={note.id} className={note.read ? 'note' : 'note unread'} onClick={() => dispatch({ type: 'read', id: note.id })}>
{!note.read && <span className="dot" />}
<span className="text">{note.text}</span>
<button type="button" className="x" aria-label="Dismiss" onClick={(event) => { event.stopPropagation(); dispatch({ type: 'dismiss', id: note.id }); }}>×</button>
</li>)}
</ul>}
{state.open && <button type="button" className="markall" onClick={() => dispatch({ type: 'readAll' })}>Mark all read</button>}
</main>
);
}A custom hook owns visibility and list updates. The component consumes a small command based model and derives the badge from the hook's items.
import { useState } from 'react';
import './styles.css';
type Note = { id: number; text: string; read: boolean };
const initial: Note[] = [
{ id: 1, text: 'New comment on your post', read: false },
{ id: 2, text: 'Build #42 passed', read: false },
{ id: 3, text: 'Weekly digest ready', read: false },
{ id: 4, text: 'Password changed', read: true },
];
function useNotifications() {
const [items, setItems] = useState(initial);
const [open, setOpen] = useState(true);
return {
items,
open,
toggle: () => setOpen((value) => !value),
read: (id: number) => setItems((list) => list.map((note) => note.id === id ? { ...note, read: true } : note)),
dismiss: (id: number) => setItems((list) => list.filter((note) => note.id !== id)),
readAll: () => setItems((list) => list.map((note) => ({ ...note, read: true }))),
};
}
export default function App() {
const model = useNotifications();
const unread = model.items.reduce((count, note) => count + Number(!note.read), 0);
return (
<main className="container">
<h1>Notification Center</h1>
<div className="bell-wrap">
<button type="button" className="bell" aria-label="Notifications" onClick={model.toggle}>🔔</button>
{unread > 0 && <span className="badge">{unread}</span>}
</div>
{model.open && <ul className="panel">
{model.items.map((note) => <li key={note.id} className={note.read ? 'note' : 'note unread'} onClick={() => model.read(note.id)}>
{!note.read && <span className="dot" />}
<span className="text">{note.text}</span>
<button type="button" className="x" aria-label="Dismiss" onClick={(event) => { event.stopPropagation(); model.dismiss(note.id); }}>×</button>
</li>)}
</ul>}
{model.open && <button type="button" className="markall" onClick={model.readAll}>Mark all read</button>}
</main>
);
}Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
A notification center shows recent messages and derives its unread badge from the messages that are still unread. Build the familiar bell and panel while keeping the notification array as the single source of truth. React should recompute the badge during render instead of storing a second count that can drift.
Implement the default App component in App.tsx. It receives no props and owns an array of { id: number; text: string; read: boolean } notifications plus an open boolean.
3.New comment on your post marks only that row as read. Its dot and highlight disappear, and the badge changes to 2.× on Build #42 passed removes that row without also marking it read. Because it was unread, the derived badge decreases by one.Mark all read clears every dot and hides the badge when the unread count reaches 0.App.tsx contains a static version of the required first frame, and styles.css contains the complete visual design. Replace the repeated mock-up with data-driven JSX and implement the interactions without changing the copy or class names.
items.filter((item) => !item.read).length during render; never store the count in state.map to mark notifications read and filter to dismiss one so React receives a new array.× is inside a clickable row, so its handler must call event.stopPropagation().Keep the notifications in state and treat the unread badge as a value you compute from them — never a second number you store and babysit.
A notification center holds a list of items, each read or unread. The bell shows how many are unread; the panel lets you read them (which clears their dot), throw them away, or clear everything at once. The temptation is to keep a count next to the list and adjust it on every action. That count is the bug: it's the same information as the list, stored twice, and the two drift apart the first time you forget to update one.
There is exactly one source of truth — the items array. Everything else is derived from it: the badge is items.filter((n) => !n.read).length, the dot on a row is !n.read, the panel is a map over items. Change the array and React re-renders; the badge recomputes for free.
The obvious version stores the count alongside the list and adjusts it by hand:
const [items, setItems] = useState(INITIAL);
const [unread, setUnread] = useState(3); // second source of truth
function markRead(id) {
setItems(items.map((n) => (n.id === id ? { ...n, read: true } : n)));
setUnread(unread - 1); // must remember to do this, every time, everywhere
}
It works until it doesn't. Mark an already-read item and unread goes negative. Dismiss an unread row and you have to remember to decrement here too. markAll has to reset it to 0. Every action now has two jobs, and the count is only ever as correct as your discipline. Derive it instead and there's nothing to keep in sync.
import { useState } from 'react';
import './styles.css';
type Note = { id: number; text: string; read: boolean };
const INITIAL: Note[] = [
{ id: 1, text: 'New comment on your post', read: false },
{ id: 2, text: 'Build #42 passed', read: false },
{ id: 3, text: 'Weekly digest ready', read: false },
{ id: 4, text: 'Password changed', read: true },
];
export default function App() {
const [items, setItems] = useState<Note[]>(INITIAL);
const [open, setOpen] = useState(true);
const unread = items.filter((n) => !n.read).length; // derived, not stored
const markRead = (id: number) =>
setItems((list) => list.map((n) => (n.id === id ? { ...n, read: true } : n)));
const dismiss = (id: number) =>
setItems((list) => list.filter((n) => n.id !== id));
const markAll = () =>
setItems((list) => list.map((n) => ({ ...n, read: true })));
return (
<main className="container">
<h1>Notification Center</h1>
<div className="bell-wrap">
<button
type="button"
className="bell"
aria-label="Notifications"
onClick={() => setOpen((o) => !o)}
>
🔔
</button>
{unread > 0 && <span className="badge">{unread}</span>}
</div>
{open && (
<ul className="panel">
{items.map((n) => (
<li
key={n.id}
className={n.read ? 'note' : 'note unread'}
onClick={() => markRead(n.id)}
>
{!n.read && <span className="dot" />}
<span className="text">{n.text}</span>
<button
type="button"
className="x"
aria-label="Dismiss"
onClick={(e) => {
e.stopPropagation();
dismiss(n.id);
}}
>
×
</button>
</li>
))}
</ul>
)}
{open && (
<button type="button" className="markall" onClick={markAll}>
Mark all read
</button>
)}
</main>
);
}
The key shift from the naive version: unread is a plain const computed during render, not state. There is nothing to increment or reset, so markRead, dismiss, and markAll each do one thing — change the array — and the badge simply follows. Every update is immutable (map for read changes, filter for dismiss) so React sees a new array and re-renders.
items has three unread → unread === 3 → badge shows 3, panel lists four rows.New comment on your post: markRead(1) maps to a new array with read: true on id 1 → re-render → unread === 2 → its dot and highlight are gone, badge reads 2.Mark all read: markAll maps every item to { ...n, read: true } → unread === 0 → the unread > 0 guard is false → the badge is not rendered at all.Dismiss is the other kind of change: instead of flipping a field, it removes the item. filter returns a new array without that id, and because the panel is a map over items, the row disappears. The one trap is the click target.
The × lives inside the row, and the row has its own onClick that marks read. Without e.stopPropagation() on the button, one click bubbles up and runs both handlers — the item is marked read on its way out, which is harmless here but is the exact bug that bites when the row action is destructive.
count state next to items is duplicated truth that drifts. Derive unread from items every render.stopPropagation — the × sits inside the clickable row, so its handler must stop the event or dismiss also fires the row's markRead.n.read = true on an item already in state won't re-render. Build a new array with map, or a new object with { ...n, read: true }.Earlier divider, all from the same derived split of items.items to localStorage in an effect so dismissals survive a reload.A reducer centralizes the state transitions while the badge remains derived from the current list. The rendered structure and behavior stay identical.
import { useReducer } from 'react';
import './styles.css';
type Note = { id: number; text: string; read: boolean };
type State = { items: Note[]; open: boolean };
type Action =
| { type: 'toggle' }
| { type: 'read'; id: number }
| { type: 'dismiss'; id: number }
| { type: 'readAll' };
const initial: State = {
open: true,
items: [
{ id: 1, text: 'New comment on your post', read: false },
{ id: 2, text: 'Build #42 passed', read: false },
{ id: 3, text: 'Weekly digest ready', read: false },
{ id: 4, text: 'Password changed', read: true },
],
};
function reducer(state: State, action: Action): State {
if (action.type === 'toggle') return { ...state, open: !state.open };
if (action.type === 'read') return { ...state, items: state.items.map((note) => note.id === action.id ? { ...note, read: true } : note) };
if (action.type === 'dismiss') return { ...state, items: state.items.filter((note) => note.id !== action.id) };
return { ...state, items: state.items.map((note) => ({ ...note, read: true })) };
}
export default function App() {
const [state, dispatch] = useReducer(reducer, initial);
const unread = state.items.filter((note) => !note.read).length;
return (
<main className="container">
<h1>Notification Center</h1>
<div className="bell-wrap">
<button type="button" className="bell" aria-label="Notifications" onClick={() => dispatch({ type: 'toggle' })}>🔔</button>
{unread > 0 && <span className="badge">{unread}</span>}
</div>
{state.open && <ul className="panel">
{state.items.map((note) => <li key={note.id} className={note.read ? 'note' : 'note unread'} onClick={() => dispatch({ type: 'read', id: note.id })}>
{!note.read && <span className="dot" />}
<span className="text">{note.text}</span>
<button type="button" className="x" aria-label="Dismiss" onClick={(event) => { event.stopPropagation(); dispatch({ type: 'dismiss', id: note.id }); }}>×</button>
</li>)}
</ul>}
{state.open && <button type="button" className="markall" onClick={() => dispatch({ type: 'readAll' })}>Mark all read</button>}
</main>
);
}A custom hook owns visibility and list updates. The component consumes a small command based model and derives the badge from the hook's items.
import { useState } from 'react';
import './styles.css';
type Note = { id: number; text: string; read: boolean };
const initial: Note[] = [
{ id: 1, text: 'New comment on your post', read: false },
{ id: 2, text: 'Build #42 passed', read: false },
{ id: 3, text: 'Weekly digest ready', read: false },
{ id: 4, text: 'Password changed', read: true },
];
function useNotifications() {
const [items, setItems] = useState(initial);
const [open, setOpen] = useState(true);
return {
items,
open,
toggle: () => setOpen((value) => !value),
read: (id: number) => setItems((list) => list.map((note) => note.id === id ? { ...note, read: true } : note)),
dismiss: (id: number) => setItems((list) => list.filter((note) => note.id !== id)),
readAll: () => setItems((list) => list.map((note) => ({ ...note, read: true }))),
};
}
export default function App() {
const model = useNotifications();
const unread = model.items.reduce((count, note) => count + Number(!note.read), 0);
return (
<main className="container">
<h1>Notification Center</h1>
<div className="bell-wrap">
<button type="button" className="bell" aria-label="Notifications" onClick={model.toggle}>🔔</button>
{unread > 0 && <span className="badge">{unread}</span>}
</div>
{model.open && <ul className="panel">
{model.items.map((note) => <li key={note.id} className={note.read ? 'note' : 'note unread'} onClick={() => model.read(note.id)}>
{!note.read && <span className="dot" />}
<span className="text">{note.text}</span>
<button type="button" className="x" aria-label="Dismiss" onClick={(event) => { event.stopPropagation(); model.dismiss(note.id); }}>×</button>
</li>)}
</ul>}
{model.open && <button type="button" className="markall" onClick={model.readAll}>Mark all read</button>}
</main>
);
}Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.