ESC

Type to search the knowledge base.

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 hook useToast()

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)

  1. Provider + context for queue
  2. Ids + timers in refs map
  3. role=“status” polite for info; role=“alert” for errors
  4. MVP — add/remove + timeout; then pause + max
  5. 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: fixed once
  • Avoid re-creating context value without memo

Footguns

  1. Timer leaks on unmount
  2. Hover pause resetting wrong duration
  3. Assertive spam for every info toast
  4. Duplicate portals if multiple providers
  5. 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.

Further reading