Back to System Design
System Design
hard
mid

How would you design a reusable toast notification system from scratch?

A context/store holding an array of toasts, an imperative API (toast.success(...)), a portal-rendered container, auto-dismiss timers, and per-toast config (type, duration, action). Discuss queueing, positioning, accessibility (aria-live), and animations.

5 min read·~25 min to think through

A toast system is a small but complete component-architecture question: global state, imperative API, portals, timers, accessibility.

Architecture

1. A store + provider — toasts are global UI state. A Context (or a tiny external store like Zustand) holds toasts: Toast[].

jsx
const ToastContext = createContext(null);

function ToastProvider({ children }) {
  const [toasts, setToasts] = useState([]);

  const remove = useCallback((id) =>
    setToasts((t) => t.filter((x) => x.id !== id)), []);

  const add = useCallback((toast) => {
    const id = crypto.randomUUID();
    setToasts((t) => [...t, { id, duration: 4000, type: "info", ...toast }]);
    return id;
  }, []);

  return (
    <ToastContext.Provider value={{ add, remove }}>
      {children}
      <ToastContainer toasts={toasts} onDismiss={remove} />
    </ToastContext.Provider>
  );
}

2. An imperative API — callers want toast.success("Saved"), not to thread context everywhere. Expose a hook useToast() returning { success, error, info, custom }, or a module-level singleton that the provider registers into.

3. Portal-rendered container — render the stack via createPortal into document.body so toasts escape parent overflow/z-index/transform contexts. Position it (top-right, bottom-center…) via a prop.

4. Auto-dismiss — each toast sets a setTimeout(duration) to remove itself; pause on hover/focus, resume on leave. Provide duration: Infinity for sticky toasts.

5. Per-toast configtype (success/error/info/warning), duration, action (button + callback), dismissible.

The things that separate a good answer

  • Queueing / limiting — cap visible toasts (e.g. 3); queue the rest and promote as slots free up, so a burst doesn't bury the screen.
  • Accessibility — the container is an aria-live region (polite for info, assertive for errors) so screen readers announce toasts; each toast is dismissible by keyboard.
  • Animations — enter/exit transitions; exit needs the toast to stay mounted until the animation finishes (AnimatePresence or a manual "exiting" state).
  • Stacking — newest on top or bottom; smooth reflow when one is removed.
  • De-duplication — optional: collapse identical rapid toasts.

The framing

"It's a global store of Toast[] behind a provider, with an imperative useToast() API so callers just say toast.error(...). The container is portaled to body to escape stacking contexts. Each toast auto-dismisses on a timer that pauses on hover. The senior details are queueing to cap visible toasts, an aria-live region for screen readers, and keeping toasts mounted through their exit animation."

Follow-up questions

  • How would you queue toasts so they don't all stack at once?
  • Why render the container in a portal?
  • How do you make toasts accessible to screen readers?
  • How do you handle the exit animation without unmounting too early?

Common mistakes

  • Rendering toasts inline instead of in a portal — clipped by overflow/z-index.
  • No pause-on-hover, so a toast with an action disappears before it's clicked.
  • No aria-live region — screen reader users never hear the toast.
  • Unmounting immediately, killing the exit animation.
  • No limit on visible toasts — a burst floods the screen.

Performance considerations

  • Toast counts are small, so rendering is cheap. The real concern is timer hygiene — clear timeouts on unmount/dismiss to avoid setting state on removed toasts, and avoid re-rendering the whole app when the toast array changes (keep the store separate from app state).

Edge cases

  • A burst of 50 toasts at once — must queue/cap.
  • A sticky toast (duration: Infinity) that only dismisses on user action.
  • Toast triggered from outside React (e.g. an API layer) — needs a singleton API.
  • Very long message text or an action button changing toast height.

Real-world examples

  • react-hot-toast and Sonner — exactly this architecture: store + portal + imperative API.
  • Form-save confirmations and error banners across an app.

Senior engineer discussion

Seniors separate concerns: a store, an imperative API, a portaled renderer. They proactively raise queueing/limiting, aria-live accessibility, exit-animation lifecycle, and timer cleanup, and consider whether the store should live outside React so non-React code can trigger toasts.

Related questions