← Back to question bank
UI ComponentSeniorHard#3013 · 60m

Build a toast notification system

Design the behavior contract for a toast notification system. Focus on state, keyboard interaction, empty/loading/error states, and how the component composes with product data.

Answer Strategy

Toasts are the question that surfaces whether you treat notifications as a queue with bounded lifetime or a heap of mounted divs. The reducer is small (add, dismiss, clear) but the surrounding policy is what matters: cap queue length, avoid duplicate spam, distinguish self-dismissing info from sticky errors, clean up timers on unmount, announce assertively for errors and politely for everything else.

Split four owners. The reducer owns queue order. The provider owns timers and the show/dismiss API. The region owns ARIA semantics (role=region, aria-label, plus per-toast role=alert/status with aria-live). Product code owns the message copy and which variant to use. With those boundaries the component remains testable without rendering and without timers.

Volunteer the failures. Without a queue cap a network storm pins a tower of toasts. Without timer cleanup on unmount, a navigation mid-toast leaks a setTimeout that wakes up dispatching into a stale store. Without polite vs assertive split, screen readers either flood users (assertive on info) or miss critical errors (polite on errors). Without keyboard reachability the toast is invisible to half your users.

Reference Implementation: Reducer-Based Toast Queue With Cleanup

Pure reducer plus a hook that owns timers. The Region renders ARIA-correct alert/status nodes per toast variant.

type Toast = {
  id: string;
  message: string;
  variant: 'info' | 'success' | 'error';
  // Persistent toasts (errors that the user must acknowledge) skip auto-dismiss.
  durationMs?: number;
};

type ToastAction =
  | { type: 'add'; toast: Toast }
  | { type: 'dismiss'; id: string }
  | { type: 'clear' };

type ToastState = { queue: Toast[] };

// Keeping the reducer pure makes queue ordering testable without timers.
export function toastReducer(state: ToastState, action: ToastAction): ToastState {
  if (action.type === 'add') {
    // Cap the queue. Newer toasts push older ones out so the stack does not
    // grow unbounded during a network storm.
    const next = [...state.queue, action.toast];
    return { queue: next.slice(-5) };
  }
  if (action.type === 'dismiss') {
    return { queue: state.queue.filter((toast) => toast.id !== action.id) };
  }
  return { queue: [] };
}

export function useToasts() {
  const [state, dispatch] = React.useReducer(toastReducer, { queue: [] });
  const timers = React.useRef<Map<string, number>>(new Map());

  const dismiss = React.useCallback((id: string) => {
    const timer = timers.current.get(id);
    if (timer !== undefined) window.clearTimeout(timer);
    timers.current.delete(id);
    dispatch({ type: 'dismiss', id });
  }, []);

  const show = React.useCallback(
    (toast: Omit<Toast, 'id'> & { id?: string }) => {
      const id = toast.id ?? crypto.randomUUID();
      dispatch({ type: 'add', toast: { ...toast, id } });

      if (toast.durationMs !== undefined) {
        const timer = window.setTimeout(() => dismiss(id), toast.durationMs);
        timers.current.set(id, timer);
      }
      return id;
    },
    [dismiss]
  );

  // Cleanup on unmount: every queued timer must be cleared, otherwise a
  // navigation away during a 6-second toast keeps a setTimeout alive.
  React.useEffect(() => {
    const map = timers.current;
    return () => {
      map.forEach((timer) => window.clearTimeout(timer));
      map.clear();
    };
  }, []);

  return { toasts: state.queue, show, dismiss };
}

export function ToastRegion({ toasts, dismiss }: {
  toasts: Toast[];
  dismiss: (id: string) => void;
}) {
  return (
    <div
      role="region"
      aria-label="Notifications"
      // aria-live + role=status on each toast announces the message without
      // moving focus. Errors use role=alert for assertive announcement.
      className="toast-region"
    >
      {toasts.map((toast) => (
        <div
          key={toast.id}
          role={toast.variant= 'error' ? 'alert' : 'status'}
          aria-live={toast.variant= 'error' ? 'assertive' : 'polite'}
        >
          <p>{toast.message}</p>
          <button type="button" onClick={()=> dismiss(toast.id)}>
            Dismiss
          </button>
        </div>
      ))}
    </div>
  );
}

Executable UI Sandbox

UI interview practice should behave like component documentation, not a static snippet. This uses the same isolation pattern as Storybook, Sandpack, CodeSandbox, and StackBlitz: editable source on one side, a sandboxed browser preview on the other. Edit the DOM code, run it, and verify focus, keyboard, pointer, and state behavior in the preview.

Browser sandbox
HTML, CSS, DOM events, focus, and keyboard behavior
Preview running...
Loading editor...

Testing Strategy

Convert the answer into observable behavior. In a mid-senior interview, say which behaviors are covered by unit tests, interaction tests, accessibility checks, and one browser smoke path.

Reducer
Test add, dismiss-by-id, and queue-cap behavior without rendering. Pure reducer = pure unit tests.
Lifecycle
Use fake timers to assert auto-dismiss for self-dismissing variants and that timers clear on unmount. Drive a mount/unmount cycle to prove no leak.
A11y
Confirm role=region with aria-label on the container; role=alert with aria-live=assertive for errors; role=status with aria-live=polite otherwise. Screen reader smoke test on the real page.
import { describe, it, expect } from 'vitest';

describe('toastReducer', () => {
  const empty = { queue: [] };

  it('appends a toast', () => {
    const state = toastReducer(empty, {
      type: 'add',
      toast: { id: '1', message: 'Saved', variant: 'success' },
    });
    expect(state.queue).toHaveLength(1);
  });

  it('caps queue length at five', () => {
    let state = empty;
    for (let i = 0; i < 8; i += 1) {
      state = toastReducer(state, {
        type: 'add',
        toast: { id: String(i), message: 'msg' + i, variant: 'info' },
      });
    }
    expect(state.queue).toHaveLength(5);
    expect(state.queue[0].id).toBe('3');
  });

  it('dismisses by id without affecting siblings', () => {
    const seeded: { queue: any[] } = {
      queue: [
        { id: 'a', message: 'A', variant: 'info' },
        { id: 'b', message: 'B', variant: 'info' },
      ],
    };
    const state = toastReducer(seeded, { type: 'dismiss', id: 'a' });
    expect(state.queue.map((toast) => toast.id)).toEqual(['b']);
  });
});

Interviewer Signal

Shows whether you can build components as interaction systems rather than visual boxes.

Constraints

  • Name the controlled and uncontrolled state.
  • Define keyboard and focus behavior.
  • Include loading, empty, disabled, and error states.

Model Answer Shape

  • Start with the accessibility role and interaction contract.
  • Separate rendering slots from state management.
  • Expose callbacks that describe user intent, not internal implementation details.

Tradeoffs

  • A headless primitive is reusable but slower to consume.
  • A product-specific component ships faster but can trap behavior in one use case.

Edge Cases

  • Focus after close, selection, deletion, or route change.
  • Large datasets and slow network responses.
  • Screen reader labels and live updates.

Testing And Proof

  • Keyboard path through the primary workflow.
  • A11y names, descriptions, and roles.
  • State transition after slow or failed data load.

Follow-Ups

  • How would this component be documented in a design system?
  • What props would you refuse to expose?