A global, queue-based notification system triggerable from anywhere in the app.
Illustration coming soon
HOW TO USE THIS CHALLENGE
1. Read the briefClarify decisions before coding.
2. Build from memoryUse the 70-minute target.
3. Study the guideCompare architecture, tests, and trade-offs.
REQUIREMENTS
Expose an imperative API (e.g. toast.success('Saved')) callable from any component without prop-drilling.
Support multiple simultaneous toasts, stacked in a fixed screen position.
Auto-dismiss after a duration, with a pause-on-hover behavior.
Support at least success/error/info variants and an optional action button.
Allow manual dismissal before the timer expires.
EDGE CASES
Rapidly firing many toasts shouldn't flood the screen — cap visible count and queue the rest, or replace duplicates of the same message.
A toast triggered during unmount of the triggering component must not error or leak its timer.
Hovering to pause, then leaving, should resume the remaining duration, not restart the full timer.
Toasts must stack in a predictable, non-overlapping order as they're added and removed.
ACCESSIBILITY
The toast container is an aria-live region (polite for info/success, assertive for errors) so screen readers announce new toasts automatically.
Toasts are not focused automatically (that would disrupt the user's task), but any action button inside one must still be reachable by keyboard.
Auto-dismiss timing must be generous enough (or pausable) to meet WCAG's timing-adjustable guidance.
SUGGESTED APPROACH
Use a singleton store (a small pub-sub or context + reducer) so any part of the tree can push a toast without threading props.
Each toast gets an id and its own timer; store remaining time so hover-pause can resume instead of reset.
Render the toast container once near the app root, subscribing to the store and animating items in/out by id.
Clean up timers in a useEffect return function keyed by toast id to avoid leaks when a toast is dismissed early.
EVALUATION RUBRIC
Toasts can be triggered from any component without prop-drilling or context wrapping at each call site.
Timers are cleaned up correctly; no leaked intervals/timeouts after dismissal or unmount.
Live region usage announces new toasts without stealing focus.
Pause-on-hover correctly resumes remaining time rather than restarting.
01Understand the product before coding
Learning goals
Expose an imperative API (e.g. toast.success('Saved')) callable from any component without prop-drilling.
Support multiple simultaneous toasts, stacked in a fixed screen position.
Auto-dismiss after a duration, with a pause-on-hover behavior.
Support at least success/error/info variants and an optional action button.
Decisions to state aloud
Rapidly firing many toasts shouldn't flood the screen — cap visible count and queue the rest, or replace duplicates of the same message.
A toast triggered during unmount of the triggering component must not error or leak its timer.
Hovering to pause, then leaving, should resume the remaining duration, not restart the full timer.
Toasts must stack in a predictable, non-overlapping order as they're added and removed.
02State model and invariants
A store owns queued toast descriptors; each visible toast has a lifecycle state and remaining duration. Use one-shot timeouts with start timestamps, not polling intervals. Polite and assertive announcements are separated by urgency.
01ToastStore exposes push, dismiss, pause, and resume commands.
02ToastViewport subscribes once near the root.
03ToastItem owns its timeout and animation completion.
04Queue policy caps visible notifications and deduplicates when configured.
05Separate live regions announce polite and urgent messages.
04Reference implementation walkthrough
Step 1
Create a tiny external store
Keep the imperative API outside component instances, expose subscribe/getSnapshot, and render exactly one viewport. Server rendering receives an empty snapshot.
On start store performance.now. Pause clears the timeout and subtracts elapsed time; resume schedules only the remaining duration. Manual dismissal cancels the same owned timer.
A toast action remains keyboard reachable, but new toasts do not steal focus. Announce concise text once; updating a visible timer must not repeatedly retrigger the live region.
Step 4
Coordinate exit and queue promotion
Dismiss marks leaving, animation completion removes it, then promotes the next queued toast. Reduced motion skips visual delay while keeping the same state transition.
SOLComplete solution, explained simply
Build it yourself first. This is one correct implementation, not the only one — read it top to bottom, then compare the shape of your version.
We are building pop-up notifications that any code can trigger with a function call, that auto-dismiss after a few seconds, that pause when you hover, and that queue up when too many are on screen. The one big idea: the list of toasts lives in a plain module-level store outside React, so toast.show() works from anywhere, and a viewport component subscribes to it. Each toast owns exactly one timer.
toast.tsx
1import{ useCallback, useEffect, useRef, useSyncExternalStore }from"react";23type Toast={4id: string;5variant:"info"|"success"|"error";6message: string;7duration: number;8};9type Snapshot={visible:Toast[]; queued:Toast[]};1011constMAX_VISIBLE=3;12letsnapshot:Snapshot={visible:[],queued:[]};13const listeners =newSet<()=>void>();1415functionset(next:Snapshot){16 snapshot = next;// always a new object so subscribers see the change17for(const fn of listeners)fn();18}1920exportconst toast ={21show(input:Omit<Toast,"id"|"duration">&{ duration?: number }){22constitem:Toast={id: crypto.randomUUID(),duration:5000,...input };23if(snapshot.visible.length<MAX_VISIBLE){24set({...snapshot,visible:[...snapshot.visible, item]});25}else{26set({...snapshot,queued:[...snapshot.queued, item]});27}28},29dismiss(id: string){30const visible = snapshot.visible.filter((t)=> t.id!== id);31const[next,...rest]= snapshot.queued;32set(next ?{visible:[...visible, next],queued: rest }:{...snapshot, visible });33},34subscribe(fn:()=>void){35 listeners.add(fn);36return()=> listeners.delete(fn);37},38getSnapshot:()=> snapshot,39getServerSnapshot:():Snapshot=>({visible:[],queued:[]}),40};4142exportfunctionToastViewport(){43const{ visible }=useSyncExternalStore(44 toast.subscribe,45 toast.getSnapshot,46 toast.getServerSnapshot,47);48return(49<>50<div className="toast-stack">51{visible.map((t)=><ToastItem key={t.id} toast={t}/>)}52</div>53{/* Two regions so an urgent error is not queued behind a polite success. */}54<div aria-live="polite" className="sr-only">55{visible.filter((t)=> t.variant!=="error").map((t)=><p key={t.id}>{t.message}</p>)}56</div>57<div role="alert" className="sr-only">58{visible.filter((t)=> t.variant==="error").map((t)=><p key={t.id}>{t.message}</p>)}59</div>60</>61);62}6364functionToastItem({toast: t }:{toast:Toast}){65const timer =useRef(0);66const startedAt =useRef(0);67const remaining =useRef(t.duration);6869const start =useCallback(()=>{70 startedAt.current=performance.now();71 timer.current=window.setTimeout(()=> toast.dismiss(t.id), remaining.current);72},[t.id]);7374const pause =useCallback(()=>{75window.clearTimeout(timer.current);76 remaining.current-=performance.now()- startedAt.current;// keep only what is left77},[]);7879useEffect(()=>{80start();81return()=>window.clearTimeout(timer.current);82},[start]);8384return(85<div86 className={"toast toast-"+ t.variant}87 onMouseEnter={pause}88 onMouseLeave={start}89 onFocus={pause}90 onBlur={start}91>92<p>{t.message}</p>93<button type="button" aria-label="Dismiss" onClick={()=> toast.dismiss(t.id)}>94 \u00d795</button>96</div>97);98}
How each part works
The store lives outside React
snapshot, listeners, and the set() helper are plain module variables. That is why toast.show() can be called from an event handler, an API layer, or anywhere, with no context or hook. useSyncExternalStore is the official bridge that lets a component read this outside store and re-render when it changes.
Every update makes a new snapshot object
set() always assigns a brand-new object. Subscribers compare by reference, so mutating the old object in place would not trigger a re-render. Immutability here is not a style choice, it is what makes the subscription work.
Queue instead of overwhelm
show() puts the toast straight on screen if fewer than MAX_VISIBLE are showing, otherwise into queued. dismiss() removes one and, if the queue has something waiting, promotes it into the visible list in the same update.
One timer per toast, tracked by elapsed time
start() records performance.now() and schedules a single setTimeout. pause() clears that timeout and subtracts how long it ran from remaining. So resume schedules only the time left, not a fresh full duration — hover for two seconds on a five-second toast and it has three seconds left when you leave.
Cleanup on unmount
The effect's return function clears the timer. Combined with dismiss() removing the toast from state (which unmounts it), there is no way to leave a setTimeout running that later calls dismiss on a toast that is already gone.
Two separate live regions
Errors go in a role="alert" region (announced immediately, interrupting). Everything else goes in an aria-live="polite" region (announced when the screen reader is idle). Splitting them means a routine success cannot delay an important error announcement. New toasts never move focus.
Why this is correct
Putting the toast list in a module-level store is what lets any code trigger a toast without prop drilling or context.
useSyncExternalStore plus always-new snapshot objects is the correct, tear-free way to read an outside store from React.
Pause and resume must track elapsed time and reschedule only the remainder, not restart the full duration.
Each toast owns exactly one timeout and clears it on dismiss, pause, and unmount, so no stale callback can fire.
Polite and assertive announcements need separate live regions so an error is never queued behind a success.
05Testing strategy
Critical behavior
Toasts can be triggered from any component without prop-drilling or context wrapping at each call site.
Timers are cleaned up correctly; no leaked intervals/timeouts after dismissal or unmount.
Live region usage announces new toasts without stealing focus.
Pause-on-hover correctly resumes remaining time rather than restarting.
Failure and boundary cases
Rapidly firing many toasts shouldn't flood the screen — cap visible count and queue the rest, or replace duplicates of the same message.
A toast triggered during unmount of the triggering component must not error or leak its timer.
Hovering to pause, then leaving, should resume the remaining duration, not restart the full timer.
Toasts must stack in a predictable, non-overlapping order as they're added and removed.
Accessibility
The toast container is an aria-live region (polite for info/success, assertive for errors) so screen readers announce new toasts automatically.
Toasts are not focused automatically (that would disrupt the user's task), but any action button inside one must still be reachable by keyboard.
Auto-dismiss timing must be generous enough (or pausable) to meet WCAG's timing-adjustable guidance.
06Performance and production hardening
Cap visible and queued counts.
Deduplicate noisy identical messages with a product-defined window.
Use timeouts rather than a global frequent interval.
Keep store snapshots immutable for reliable subscriptions.
QAInterview questions and model answers
Answer aloud first. Then open the model answer and compare state ownership, failure handling, accessibility, and trade-offs—not exact wording.
Model answer
It supports an imperative API without prop drilling while one viewport subscribes through a React-compatible snapshot contract.