On this page
Toast Notification Systemhigh-yield
Last reviewed 22 Sept 2026
Problem
Build a toast (snackbar) system. Any component can show a notification that disappears after a few seconds:
function App() { return <ToastProvider max={3}><SaveButton /></ToastProvider>;}
function SaveButton() { const toast = useToast(); return <button onClick={() => toast.show('Saved!', { type: 'success' })}>Save</button>;}Clarifying questions
- How long does a toast stay? Default 3 s;
duration: Infinityfor sticky ones. - Maximum visible at once? e.g. 3; extra toasts wait in a queue.
- Pause the timer on hover? Yes — users need time to read.
- Can toasts contain an action (e.g. “Undo”)? Follow-up.
- Screen readers? Announce toasts with a live region.
Approach
A context provider owns the toast list and exposes show and dismiss through a useToast hook, so any component can trigger a toast without prop drilling. The list is managed by a pure reducer (add, remove), and the visible toasts are simply the first max entries — the rest are queued automatically. Each rendered toast owns its own timer, which it pauses while hovered.
Step-by-step build
Step 1 — the reducer
function toastReducer(state, action) { switch (action.type) { case 'add': return [...state, action.toast]; case 'remove': return state.filter((t) => t.id !== action.id); default: return state; }}Step 2 — provider and hook
const ToastContext = createContext(null);
function useToast() { const ctx = useContext(ToastContext); if (!ctx) throw new Error('useToast must be used inside <ToastProvider>'); return ctx;}Step 3 — auto-dismiss with pause on hover
Track the remaining time; hovering clears the timer and records how much time was left.
useEffect(() => { if (paused || toast.duration === Infinity) return; const start = Date.now(); const t = setTimeout(() => onDismiss(toast.id), remaining.current); return () => { clearTimeout(t); remaining.current -= Date.now() - start; };}, [paused]);Step 4 — accessibility
The container is a live region: role="status" with aria-live="polite" for normal toasts; errors use role="alert". Each toast has a close button.
Final code
import { createContext, useCallback, useContext, useEffect, useMemo, useReducer, useRef, useState } from 'react';
export function toastReducer(state, action) { switch (action.type) { case 'add': // same id again → replace instead of duplicating (e.g. "Saving…" → "Saved") return state.some((t) => t.id === action.toast.id) ? state.map((t) => (t.id === action.toast.id ? action.toast : t)) : [...state, action.toast]; case 'remove': return state.filter((t) => t.id !== action.id); case 'clear': return []; default: return state; }}
export function visibleToasts(state, max) { return state.slice(0, max); // the rest wait in the queue}
const ToastContext = createContext(null);
export function useToast() { const ctx = useContext(ToastContext); if (!ctx) throw new Error('useToast must be used inside <ToastProvider>'); return ctx;}
let nextId = 0;
export function ToastProvider({ children, max = 3, defaultDuration = 3000 }) { const [toasts, dispatch] = useReducer(toastReducer, []);
const dismiss = useCallback((id) => dispatch({ type: 'remove', id }), []); const show = useCallback( (message, { type = 'info', duration = defaultDuration, id = `t${++nextId}`, action } = {}) => { dispatch({ type: 'add', toast: { id, message, type, duration, action } }); return id; }, [defaultDuration], ); const api = useMemo(() => ({ show, dismiss, clear: () => dispatch({ type: 'clear' }) }), [show, dismiss]);
return ( <ToastContext.Provider value={api}> {children} <div className="toasts" role="region" aria-label="Notifications"> {visibleToasts(toasts, max).map((t) => ( <Toast key={t.id} toast={t} onDismiss={dismiss} /> ))} </div> </ToastContext.Provider> );}
function Toast({ toast, onDismiss }) { const [paused, setPaused] = useState(false); const remaining = useRef(toast.duration);
useEffect(() => { if (paused || toast.duration === Infinity) return; const start = Date.now(); const timer = setTimeout(() => onDismiss(toast.id), remaining.current); return () => { clearTimeout(timer); remaining.current -= Date.now() - start; // keep the time left for when hover ends }; }, [paused, toast.id, toast.duration, onDismiss]);
return ( <div className={`toast toast-${toast.type}`} role={toast.type === 'error' ? 'alert' : 'status'} aria-live={toast.type === 'error' ? 'assertive' : 'polite'} onMouseEnter={() => setPaused(true)} onMouseLeave={() => setPaused(false)} onFocus={() => setPaused(true)} onBlur={() => setPaused(false)} > <span>{toast.message}</span> {toast.action && ( <button onClick={() => { toast.action.onClick(); onDismiss(toast.id); }}>{toast.action.label}</button> )} <button aria-label="Dismiss notification" onClick={() => onDismiss(toast.id)}>×</button> </div> );}Edge cases
- More than
maxtoasts: extras wait and appear as visible ones are dismissed; their timers only start once rendered. - Hovering pauses the timer and resumes with the remaining time, not a fresh full duration.
duration: Infinitytoasts stay until closed.- Showing a toast with an existing
idupdates it in place (“Saving…” → “Saved”). useToastoutside the provider throws a clear error instead of failing silently.
Follow-ups
- Call it from outside React (API helpers): keep a module-level store with
subscribe, and have the provider subscribe to it — the same idea as an EventEmitter. - Promise toasts:
toast.promise(save(), { loading, success, error })updates one toast by id. - Animations: keep a “leaving” state so the toast can animate out before removal.
- Portals: render the container with
createPortalintodocument.bodyso parents withoverflow: hiddencannot clip it.
Common mistakes
- A single global timer for all toasts.
- Restarting the full timer after hover instead of resuming.
- Passing
showdown through props everywhere instead of context. - Toasts that are invisible to screen readers (no live region) or impossible to close.
Related
- Uses: context and reducers from the React track.
- Pattern: EventEmitter for triggering toasts from non-React code.