Back to React
React
medium
mid

How would you build a notification system with queuing and auto dismiss behavior in React?

Toast manager: context/store of active toasts, a `<ToastContainer/>` portal that renders them stacked, imperative API (`toast.success(msg)`) backed by the store, per-toast options (variant, duration, dismissible, action), auto-dismiss timers, queueing if max-visible exceeded, swipe-to-dismiss, focus management, ARIA live region for accessibility.

5 min read·~30 min to think through

A toast system has small visible surface and a lot of detail underneath: queueing, timing, focus, accessibility, animations. Build it as a manager (store + imperative API) with a container (renders).

1. The API

js
toast("Saved");
toast.success("Order placed");
toast.error("Could not save", { duration: 0 });   // sticky
toast.promise(api.save(), {
  loading: "Saving...",
  success: "Saved",
  error: "Failed",
});
toast.dismiss(id);

2. The store

js
class ToastStore {
  constructor() {
    this.toasts = [];
    this.subs = new Set();
  }
  add({ id = crypto.randomUUID(), variant = "info", message, duration = 4000, action } = {}) {
    const t = { id, variant, message, duration, action, createdAt: Date.now() };
    this.toasts = [...this.toasts, t];
    this._notify();
    if (duration > 0) setTimeout(() => this.dismiss(id), duration);
    return id;
  }
  dismiss(id) {
    this.toasts = this.toasts.filter((t) => t.id !== id);
    this._notify();
  }
  subscribe(fn) { this.subs.add(fn); return () => this.subs.delete(fn); }
  _notify() { for (const fn of this.subs) fn(this.toasts); }
}

const store = new ToastStore();

export const toast = (message, options) => store.add({ message, ...options });
toast.success = (msg, opts) => toast(msg, { ...opts, variant: "success" });
toast.error = (msg, opts) => toast(msg, { ...opts, variant: "error", duration: 0 });
toast.promise = (promise, msgs) => {
  const id = toast(msgs.loading, { duration: 0 });
  promise
    .then(() => { store.dismiss(id); toast.success(msgs.success); })
    .catch(() => { store.dismiss(id); toast.error(msgs.error); });
  return promise;
};
toast.dismiss = (id) => store.dismiss(id);

3. The container

jsx
function ToastContainer({ position = "top-right", maxVisible = 5 }) {
  const [toasts, setToasts] = useState(store.toasts);
  useEffect(() => store.subscribe(setToasts), []);

  const visible = toasts.slice(0, maxVisible);
  const queued = toasts.length - visible.length;

  return createPortal(
    <div className={`toast-stack toast-${position}`} role="region" aria-label="Notifications">
      <ol aria-live="polite" aria-relevant="additions">
        {visible.map((t) => <ToastItem key={t.id} toast={t} onDismiss={() => toast.dismiss(t.id)} />)}
      </ol>
      {queued > 0 && <div className="toast-queued">+{queued} more</div>}
    </div>,
    document.body
  );
}

4. The toast item

jsx
function ToastItem({ toast: t, onDismiss }) {
  return (
    <li
      className={`toast toast-${t.variant}`}
      role={t.variant === "error" ? "alert" : "status"}
    >
      <Icon variant={t.variant} aria-hidden />
      <span>{t.message}</span>
      {t.action && <button onClick={t.action.onClick}>{t.action.label}</button>}
      <button aria-label="Dismiss" onClick={onDismiss}>×</button>
    </li>
  );
}

5. Auto-dismiss

A setTimeout per toast. Cancel on hover to give the user time to read:

jsx
useEffect(() => {
  if (t.duration <= 0 || paused) return;
  const id = setTimeout(onDismiss, remaining);
  return () => clearTimeout(id);
}, [t.duration, paused, remaining, onDismiss]);

Track remaining = duration - elapsed so pausing on hover and resuming on leave continues where it left off.

6. Queueing

If toasts.length > maxVisible, only the first N show. As one is dismissed, the next surfaces. Show a "+3 more" indicator.

7. Animations

  • Slide/fade in on add, out on dismiss.
  • Use Framer Motion's <AnimatePresence> or CSS transitions.
  • Respect prefers-reduced-motion.

8. Swipe-to-dismiss

For mobile: pointerdown → track translateX → above threshold → dismiss.

9. Accessibility — the part most libraries get wrong

  • Toast container wrapped in role="region" with aria-label="Notifications".
  • Live region: aria-live="polite" for status, role="alert" (which is implicitly aria-live="assertive") for errors.
  • Don't auto-focus the toast — it disrupts the user.
  • Focus management: when a toast has an action button, ensure keyboard users can reach it (Tab into the live region) — but don't auto-focus.
  • Sufficient duration — minimum 5 seconds for non-trivial messages so users have time to read.
  • Dismiss buttons with accessible names.
  • Persistent errors that require action shouldn't auto-dismiss.

10. Don't overdo it

Toasts should be non-blocking, non-critical. Errors that demand action belong in a modal or inline, not a toast that disappears.

Real-world libraries

react-hot-toast, sonner, react-toastify — all solve this. Recommend adopting unless your design system requires custom.

Interview framing

"A central store of active toasts plus a portal-rendered container. The imperative API (toast.success(msg)) is a thin wrapper over store.add. Each toast has variant, message, duration, optional action. Auto-dismiss via setTimeout (with pause-on-hover by tracking remaining time). Render at most N visible, queue the rest with a 'more' indicator. Animate in/out — Framer Motion or CSS — with reduced-motion respect. Accessibility is the part most homegrown systems get wrong: live region with aria-live='polite' for status, role='alert' for errors, don't auto-focus the toast, persistent errors shouldn't auto-dismiss. And in real life, adopt react-hot-toast or sonner unless the design system requires custom."

Follow-up questions

  • How do you pause auto-dismiss on hover?
  • Why use aria-live for the container?
  • How do you handle a toast with a required action?
  • When is a toast the wrong UX choice?

Common mistakes

  • Auto-focusing the toast — disrupts user.
  • Auto-dismissing errors that need action.
  • No queue — many toasts overlap.
  • Missing live region — screen readers don't hear.
  • No reduced-motion respect.

Performance considerations

  • Few toasts; perf rarely matters. Don't re-create the store across renders.

Edge cases

  • Toast dispatched during route change.
  • Many rapid-fire toasts (rate-limit on the store).
  • Promise toast where promise resolves before mount.
  • Mobile swipe vs tap-to-dismiss.

Real-world examples

  • react-hot-toast, sonner, react-toastify.
  • Stripe / Linear / Vercel toast UX.

Senior engineer discussion

Seniors adopt an existing toast library, but if rolling: get accessibility right, handle pause-on-hover, queue gracefully, and don't use toasts for action-required errors.

Related questions