Toast Notification System
Machine-coding brief for toasts — queue, auto-dismiss, a11y live regions, stacking, and imperative API.
intermediate4 min read
- machine-coding
- interview
- react
- a11y
Problem statement
Build a toast notification system: fire ephemeral messages from anywhere (success/error/info), stack them, auto-dismiss, allow manual close. Interviewers score global state/API design, timers cleanup, and which live region politeness you choose.
Requirements
Must have
- Show toast with message + variant
- Auto-dismiss after timeout (e.g. 4s)
- Manual dismiss button
- Stack multiple toasts
- Accessible announcements
- Imperative API:
toast.success("Saved")or hookuseToast()
Should have
- Pause timeout on hover/focus
- Max stack size (drop oldest)
- Optional action button (“Undo”)
Nice to have
- Position prop (top-right, bottom-center)
- Exit animations with reduced motion
- Promise helper
toast.promise(p, { loading, success, error })
Planning (5 minutes out loud)
- Provider + context for queue
- Ids + timers in refs map
- role=“status” polite for info; role=“alert” for errors
- MVP — add/remove + timeout; then pause + max
- Don’t steal focus into toasts by default
Architecture
ToastProvider
├── state: Toast[]
├── api: push / dismiss
└── ToastViewport
└── ToastItem
Types
type ToastVariant = "info" | "success" | "error";
type Toast = {
id: string;
message: string;
variant: ToastVariant;
durationMs: number;
};
type ToastApi = {
push: (t: Omit<Toast, "id"> & { id?: string }) => string;
dismiss: (id: string) => void;
success: (message: string) => string;
error: (message: string) => string;
};
Implementation sketch
const ToastContext = createContext<ToastApi | null>(null);
export function useToast() {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error("useToast within ToastProvider");
return ctx;
}
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const timers = useRef(new Map<string, number>());
const dismiss = useCallback((id: string) => {
setToasts((t) => t.filter((x) => x.id !== id));
const handle = timers.current.get(id);
if (handle) window.clearTimeout(handle);
timers.current.delete(id);
}, []);
const push = useCallback(
(input: Omit<Toast, "id"> & { id?: string }) => {
const id = input.id ?? crypto.randomUUID();
const toast: Toast = {
id,
message: input.message,
variant: input.variant,
durationMs: input.durationMs ?? 4000,
};
setToasts((prev) => [...prev, toast].slice(-5)); // max 5
if (toast.durationMs > 0) {
const handle = window.setTimeout(() => dismiss(id), toast.durationMs);
timers.current.set(id, handle);
}
return id;
},
[dismiss]
);
const api = useMemo<ToastApi>(
() => ({
push,
dismiss,
success: (message) => push({ message, variant: "success", durationMs: 4000 }),
error: (message) => push({ message, variant: "error", durationMs: 6000 }),
}),
[push, dismiss]
);
useEffect(() => () => timers.current.forEach((h) => window.clearTimeout(h)), []);
return (
<ToastContext.Provider value={api}>
{children}
<div className="toast-viewport">
{toasts.map((t) => (
<div
key={t.id}
className={`toast ${t.variant}`}
role={t.variant === "error" ? "alert" : "status"}
aria-live={t.variant === "error" ? "assertive" : "polite"}
aria-atomic="true"
onMouseEnter={() => {
const h = timers.current.get(t.id);
if (h) window.clearTimeout(h);
}}
onMouseLeave={() => {
const handle = window.setTimeout(() => dismiss(t.id), t.durationMs);
timers.current.set(t.id, handle);
}}
>
<p>{t.message}</p>
<button type="button" aria-label="Dismiss notification" onClick={() => dismiss(t.id)}>
×
</button>
</div>
))}
</div>
</ToastContext.Provider>
);
}
Imperative escape hatch (optional)
// module-level listener pattern for non-React code
let handler: ToastApi["push"] | null = null;
export const toast = {
success: (m: string) => handler?.({ message: m, variant: "success", durationMs: 4000 }),
};
// Provider registers handler in useEffect
Accessibility essentials
- Polite vs assertive by severity
- Dismiss button labeled
- Don’t auto-focus toasts (unexpected context switch)
- If toast has Undo, that control must be reachable and timeout longer
- Mirror critical errors in page UI too — toasts alone are easy to miss
Performance notes
- Cap queue length
- Portal viewport
position: fixedonce - Avoid re-creating context value without memo
Footguns
- Timer leaks on unmount
- Hover pause resetting wrong duration
- Assertive spam for every info toast
- Duplicate portals if multiple providers
- State updates after dismiss race
Interview out-loud answer
Toasts are a provider-owned queue with ids and timeout handles. The API exposes success/error helpers. Viewport stacks messages; errors use alert/assertive, others status/polite. Auto-dismiss pauses on hover, and we cap the stack. Focus stays put unless the toast includes an explicit action.
Related on this site
- Accessible Modal Dialog
- Offline Status Indicator
- Design a Notification System UI
- Machine Coding Interview Framework