Forms need a place to put outcomes that are not field errors. Toasts are the usual answer, and the usual mess: five libraries, no queue, focus stolen, and a success toast for "navigated to page."
I treat notifications as a small architecture: ownership, queueing, accessibility, and explicit non-goals.
What a toast is for
Good toast jobs:
- Confirm a background mutation succeeded ("Invoice sent")
- Report a non-blocking failure the user can retry ("Sync failed: Retry")
- Surface a system event while the user stays on the same view
Bad toast jobs:
- Replacing inline field validation
- Announcing every route change
- Carrying long instructions (use a dialog or page)
- Being the only record of an error that must be fixed before continuing
If the user must act, prefer inline UI or a dialog. Toasts disappear. Disappearing requirements are how support tickets are born.
Ownership
One module owns the queue. Features call notify(), they do not mount their own
portal.
ts// src/notifications/types.ts, illustrative export type Notice = { id: string; tone: 'info' | 'success' | 'danger'; title: string; description?: string; durationMs?: number; // undefined = sticky until dismiss action?: { label: string; onClick: () => void }; }; export type NotifyInput = Omit<Notice, 'id'> & { id?: string };
ts// src/notifications/store.ts, illustrative tiny store type Listener = (notices: Notice[]) => void; let notices: Notice[] = []; const listeners = new Set<Listener>(); function emit() { for (const l of listeners) l(notices); } export function subscribe(listener: Listener) { listeners.add(listener); listener(notices); return () => listeners.delete(listener); } export function notify(input: NotifyInput) { const id = input.id ?? crypto.randomUUID(); notices = [...notices.filter((n) => n.id !== id), { ...input, id }]; emit(); return id; } export function dismiss(id: string) { notices = notices.filter((n) => n.id !== id); emit(); }
This can be Zustand, context, or a framework toast kit, the rule is one queue, not the implementation logo.
Queueing rules
Without rules, overlapping mutations spam the corner.
Rules I use:
- Dedupe by id for the same logical event (
invoice-sent-${id}) - Cap visible toasts (e.g. 3); overflow waits or replaces oldest info
- Danger stays longer than success (or sticky with dismiss)
- Success auto-dismiss; errors often need a manual close or action
- Never stack five successes for one bulk action, one summary toast
ts// features/invoices/sendInvoice.ts, illustrative call site import { notify } from '@/notifications/store'; export async function sendInvoice(id: string) { const res = await fetch(`/api/invoices/${id}/send`, { method: 'POST' }); if (!res.ok) { notify({ id: `invoice-send-fail-${id}`, tone: 'danger', title: 'Could not send invoice', description: 'Check the recipient email and try again.', durationMs: undefined, }); throw new Error('send failed'); } notify({ id: `invoice-send-ok-${id}`, tone: 'success', title: 'Invoice sent', durationMs: 4000, }); }
Accessibility: live regions, not focus theft
Toasts should announce, not steal focus (unless the toast contains a critical action that requires it, rare).
tsx// src/notifications/Toaster.tsx, illustrative live region import { useEffect, useState } from 'react'; import { dismiss, subscribe, type Notice } from './store'; export function Toaster() { const [items, setItems] = useState<Notice[]>([]); useEffect(() => subscribe(setItems), []); useEffect(() => { const timers = items .filter((n) => n.durationMs != null) .map((n) => window.setTimeout(() => dismiss(n.id), n.durationMs as number), ); return () => timers.forEach(clearTimeout); }, [items]); return ( <div className="toaster" aria-live="polite" aria-relevant="additions text" aria-atomic="false" > {items.map((n) => ( <div key={n.id} className="toast" data-tone={n.tone} role={n.tone === 'danger' ? 'alert' : 'status'} > <p>{n.title}</p> {n.description ? <p>{n.description}</p> : null} <button type="button" onClick={() => dismiss(n.id)} > Dismiss </button> </div> ))} </div> ); }
Notes:
aria-live="polite"for most success/info;role="alert"for urgent errors- Do not move focus into the toast on every save
- Honor
prefers-reduced-motionfor slide animations - Ensure dismiss controls are keyboard reachable when the toast is sticky
When not to toast
| Situation | Prefer |
|---|---|
| Field-level validation | Inline error + focus |
| Destructive confirm | Dialog |
| Full-page failure | Error boundary / route error UI |
| Long-running job | Dedicated status panel / progress |
| User already sees the new data | Silent success or subtle inline confirm |
Silent success is underrated. If the list updates and the row appears, maybe you do not need fireworks.
Failure modes
Toast-as-logger. Developers notify on every console.warn path. Users learn
to ignore the corner, then miss the one danger toast that mattered.
Focus stolen into the toast. Saving a form jumps assistive tech into a temporary region; the user's place in the form is gone. Announce politely; keep focus where the work is.
No dismiss for sticky errors. An error toast without a close control (and without an action) becomes inaccessible wallpaper.
Duplicate libraries. Feature A uses sonner, feature B uses a home-grown
portal, feature C uses the component kit's snackbar. Pick one owner module.
I will not invent "N% fewer support tickets" from toast discipline. The review signal is a single queue and a written when-not-to-toast table the team can argue with.
Continuity
Forms produce outcomes; toasts route the non-blocking ones. Error boundaries cover when the failure is not a toast but a render crash, and how granular recovery should be.
Takeaway
One notification queue, live-region announcements, and the discipline to skip toasts when inline UI or silence is clearer, notifications are product UX, not confetti.