UI build: toast notification system
One toast per id, the oldest on screen, the next up on dismiss, regions already there, and no timer running while someone reads.
Predict first
Before you read: predict
ExamHall's answer page raises toasts through a small store: toast.add(t) dispatches { type: 'add', toast: t } to this reducer, and <Toaster/> shows visible in order, one line per toast.
Autosave calls toast.add({ id: 'autosave', message: 'Saving…' }) when a save starts and toast.add({ id: 'autosave', message: 'Saved' }) when it ends. A candidate answers two questions, so two saves run, one after the other, and nobody dismisses anything.
What does the toaster show?
const MAX_VISIBLE = 3; function toastsReducer(state, action) { if (action.type === 'add') { if (state.visible.length < MAX_VISIBLE) return { ...state, visible: [...state.visible, action.toast] }; return { ...state, queue: [...state.queue, action.toast] }; } if (action.type === 'dismiss') { const visible = state.visible.filter((t) => t.id !== action.id); const [next, ...rest] = state.queue; return next ? { visible: [...visible, next], queue: rest } : { visible, queue: [] }; } return state;}The 30-second version
Keep toasts in a store outside React, as two lists: on screen (at most N) and waiting. A pure reducer adds, updates by id, dismisses and promotes the oldest waiting toast. Render a polite region and an alert region, empty, from the start. Start each timer when its toast is shown, and pause it on hover and on focus.
One toast per id, the oldest on screen, the next up on dismiss, regions already there, and no timer running while someone reads.
How it works
Step 1 of 12
toast.add() and toast.dismiss() live in a plain module, so non-component code can raise a toast; <Toaster/> reads it with useSyncExternalStore.Analogy: An opera house's surtitles (opera)
Above the stage of an opera house, a screen shows the translation; an operator in the booth sends each line from a cue list.
| At the opera | In the toaster |
|---|---|
| the screen holds at most two lines; the next cues wait in order on the operator's list | visible capped at max, the rest in queue, oldest first (steps 2, 4) |
| a corrected translation for cue 41 replaces cue 41's words where they stand | add with a known id updates that toast in place (step 3) |
| clearing a line brings the next cue up | dismiss promotes the oldest waiting toast (step 5) |
| surtitles are silent and wait for the audience to look up; the house manager stopping the show for an evacuation speaks over everything | role="status" against role="alert" (step 7) |
| the operator holds a line while the singer repeats the verse | the timer paused while someone reads (step 10) |
In real terms: each toast is one entry in one of two lists, found by its id; only the reducer moves it, and only a shown toast's clock runs.
Breaks down when: surtitles follow the music, not a clock; a toast leaves on a timer unless someone is reading it.
Contrasting case: a printed programme handed out at the door: a page that holds everything at once and announces nothing.
Examples, run for real
4.1 ExamHall EXH-3620: autosave stacks toasts
ExamHall's first toaster appended every toast.add, so three saves of one answer page posted six toasts under the id autosave (EXH-3620). MR !3920 looks the id up first, and gives the connection-lost toast its own role="alert" region.
4.1 EXH-3620 first toaster · 3 saves (6 adds, id autosave) · toasts held under autosave: 6 · on screen 3 · waiting 3
4.1 MR !3920 reference build · 3 saves (6 adds, id autosave), then Connection lost · toasts held: 2 · role="status": Saved · role="alert": Connection lost · focus: the answer box
Each save updates the one toast in place (step 3). The alert lands in the alert region, and the candidate keeps typing in the answer box: no toast took focus (steps 6–8).
4.2 Payments ledger: a payout run
The reference build, with max 3 and every toast's duration 5000, shows five payout toasts posted at once; the clerk rests the pointer on "Payout 1 sent" from 2,000 ms to 9,000 ms.
4.2 Payout 1 sent · on screen at 0 ms · gone at 12,000 ms
4.2 Payout 2 sent · on screen at 0 ms · gone at 5,000 ms
4.2 Payout 3 sent · on screen at 0 ms · gone at 5,000 ms
4.2 Payout 4 sent · on screen at 5,000 ms · gone at 10,000 ms
4.2 Payout 5 sent · on screen at 5,000 ms · gone at 10,000 ms
Payouts 4 and 5 wait until payouts 2 and 3 leave, then get their own 5,000 ms (steps 5 and 9); payout 1's clock stood still for 7,000 ms and then ran its last 3,000 (step 10).
Try it
Answer first, then check. Nothing is saved.
Question 1 of 8
A payments ledger's toaster uses this reducer; <Toaster/> shows visible in order:
A payout run posts five toasts, "Payout 1 sent" to "Payout 5 sent" (ids payout-1 to payout-5). The clerk dismisses Payout 1, then Payout 2. Then the day's ledger closes and posts "Ledger closed" (id ledger-closed).
What does the toaster show?
const MAX_VISIBLE = 3; function toastsReducer(state, action) { if (action.type === 'add') { const t = action.toast; const has = (list) => list.some((x) => x.id === t.id); const update = (list) => list.map((x) => (x.id === t.id ? { ...x, ...t } : x)); if (has(state.visible)) return { ...state, visible: update(state.visible) }; if (has(state.queue)) return { ...state, queue: update(state.queue) }; if (state.visible.length < MAX_VISIBLE) return { ...state, visible: [...state.visible, t] }; return { ...state, queue: [...state.queue, t] }; } if (action.type === 'dismiss') { return { visible: state.visible.filter((x) => x.id !== action.id), queue: state.queue.filter((x) => x.id !== action.id), }; } return state;}Question 2 of 8
A box office uses this lesson's reference build (steps 2–10) with max 2 and every toast's duration 5000. Times are from the first toast:
- 0 ms:
Seat held; 1,000 ms:Card charged; 2,000 ms:Ticket emailed; 4,000 ms:Receipt ready; - the buyer rests the pointer on
Seat heldfrom 3,000 ms to 8,000 ms; nobody clicks anything.
Put the four toasts in the order they leave the screen, the first one first.
Your order
- 1empty
- 2empty
- 3empty
- 4empty
Tap the lines in the order you expect.
Question 3 of 8
A payments ledger's reconciliation page raises two toasts. One: "Statement imported: 412 lines", after an import the clerk started. Two: "Bank feed disconnected: new payments are not being recorded. Reconnect", with a Reconnect button; the clerk must reconnect before closing the day.
Which live regions should hold them?
Question 4 of 8
A chat app's undo toast, created with duration: 6000 and removed by onDismiss:
A member deletes a message and "Message deleted" appears. At 1 s she moves the pointer onto the toast. At 2 s she presses Tab until Undo has focus. At 3 s she moves the pointer off the toast; focus stays on Undo while she decides.
When does the toast leave?
function Toast({ toast, onDismiss }) { const [hovered, setHovered] = useState(false); const remaining = useRef(toast.duration); useEffect(() => { if (hovered) return; const startedAt = Date.now(); const timer = setTimeout(() => onDismiss(toast.id), remaining.current); return () => { clearTimeout(timer); remaining.current -= Date.now() - startedAt; }; }, [hovered]); return ( <div className="toast" onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)}> {toast.message} <button onClick={toast.onUndo}>Undo</button> </div> );}Question 5 of 8
the interviewer says: "Build createToastStore(max), returning { add, dismiss, subscribe, getSnapshot }, and <Toaster store={store} />. add({ id, message, tone, duration, action }) shows a toast; tone is 'status' (the default) or 'alert'. Show at most max toasts, oldest first, and queue the rest. Adding an id that already exists updates that toast. Dismissing one shows the next waiting toast. Status toasts leave after duration ms (default 5000); alert toasts stay until dismissed. A toast's timer must not run out while the pointer is over it or focus is inside it. Never move focus." You have 35 minutes, and ExamHall's autosave is the use case.
Before you type: say your test list out loud.
Name at least six tests, each with what it does and what it expects.
Question 6 of 8
the interviewer says: "Build createToastStore(max), returning { add, dismiss, subscribe, getSnapshot }, and <Toaster store={store} />. add({ id, message, tone, duration, action }) shows a toast; tone is 'status' (the default) or 'alert'. Show at most max toasts, oldest first, and queue the rest. Adding an id that already exists updates that toast. Dismissing one shows the next waiting toast. Status toasts leave after duration ms (default 5000); alert toasts stay until dismissed. A toast's timer must not run out while the pointer is over it or focus is inside it. Never move focus." You have 35 minutes, and ExamHall's autosave is the use case.
Your blank file exports createToastStore and Toaster, with useState, useRef, useEffect, useReducer, useSyncExternalStore, setTimeout, clearTimeout and Date in scope.
Build it now, in 35 minutes.
Question 7 of 8
A video app's toaster updates a toast by dropping the old copy and adding the new one:
On screen, in order: "Uploading 10%" (id upload), "Added to Watch later" and "Comment posted"; "New follower" waits in the queue. The upload then calls toast.add({ id: 'upload', message: 'Uploading 60%' }).
What does the toaster show?
const MAX_VISIBLE = 3; function toastsReducer(state, action) { if (action.type === 'add') { const s = toastsReducer(state, { type: 'dismiss', id: action.toast.id }); // drop the old copy first if (s.visible.length < MAX_VISIBLE) return { ...s, visible: [...s.visible, action.toast] }; return { ...s, queue: [...s.queue, action.toast] }; } if (action.type === 'dismiss') { const visible = state.visible.filter((t) => t.id !== action.id); const queue = state.queue.filter((t) => t.id !== action.id); if (visible.length < MAX_VISIBLE && queue.length > 0) return { visible: [...visible, queue[0]], queue: queue.slice(1) }; return { visible, queue }; } return state;}Question 8 of 8
A shop's toast store sets each toast's timer in add; its toastsReducer shows at most two toasts and, on dismiss, moves the oldest waiting toast on screen:
At 0 s the page adds "Added to basket" (duration: 4000) and "Price dropped" (duration: 6000). At 1 s it adds "Back in stock" (duration: 4000). Nobody hovers or clicks.
How long is "Back in stock" on screen?
function createToastStore() { let state = { visible: [], queue: [] }; const listeners = new Set(); const dispatch = (action) => { state = toastsReducer(state, action); listeners.forEach((l) => l()); }; return { add(toast) { dispatch({ type: 'add', toast }); setTimeout(() => dispatch({ type: 'dismiss', id: toast.id }), toast.duration); }, dismiss: (id) => dispatch({ type: 'dismiss', id }), subscribe: (l) => { listeners.add(l); return () => listeners.delete(l); }, getSnapshot: () => state, };}Go deeper
Pro tips
- Keep toasts in an external store with a tiny API (
toast.add,toast.dismiss), so non-React code such as the autosave queue can raise one. When: the first lines of the build, before<Toaster/>(step 1). Verify (RAN,tips.mjs):
(The curriculum row's tip, [SELF], re-executed here.)tip 1 toast.add from a plain function on the clock at 1,000 ms (no component, no hook) · on screen before: 0 · after: Saved - Run your build against the list on the virtual clock before you call it done:
TOAST_IMPL=./my-toaster.jsx node --test toast.test.mjs. A toaster that pauses only on hover looks finished with a mouse. When: practising the build at home (steps 10–12). Verify (RAN,tips.mjs):tip 2 TOAST_IMPL=./drafts/m3-hover-only.jsx (pauses on hover only): exit 1 · tests 10 · pass 9 · fail 1 (T9)
Source
- MDN, ARIA: status role (the curriculum row's first source), https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/status_role (
mdn-status-role.txt). "The status role defines a live region containing advisory information for the user that is not important enough to be an alert.". "Do not give focus to the status when its content updates." (step 8). As of 2026-10-12. - W3C, Understanding Success Criterion 2.2.1: Timing Adjustable (the row's second source), https://www.w3.org/WAI/WCAG22/Understanding/timing-adjustable.html (
wcag22-timing-adjustable.txt). "Let users turn off, adjust, or extend time limits." (step 10). It keys no item. As of 2026-10-12. - W3C, Accessible Rich Internet Applications (WAI-ARIA) 1.2 (a standard), https://www.w3.org/TR/wai-aria-1.2/ (
wai-aria-12.txt).alert: "A type of live region with important, and usually time-sensitive, information." (step 7);aria-livepolite: "Indicates that updates to the region should be presented at the next graceful opportunity, such as at the end of speaking the current sentence or when the user pauses typing." (step 7); "Neither authors nor user agents are required to set or manage focus to an alert in order for it to be processed." (step 8); "If an author desires focus to move to a message when it is conveyed, the author SHOULD use alertdialog instead of alert.". As of 2026-10-12. - react.dev, useSyncExternalStore, https://react.dev/reference/react/useSyncExternalStore (
react-use-sync-external-store.txt). Linked from step 1 for the store; the prerequisite owns it. It keys nothing here. As of 2026-10-12.
Related lessons
Checked against the tools and docs as of 2026-10-12.