Try first
Frame your answer before reading the reference
Candidate prompt
Design the toast feedback layer for a multi-route web app. After a user saves a profile, any feature or service can show a success message; an edit conflict may show Review or Undo. Focus first on the race where manual dismiss and an expiry timer fire together: the toast and timer must be cleaned up once. Explain where records, rendering, and timers live, then show how a burst, route change, and screen-reader update remain safe.
Constraints
- Show no more than three toasts.
- Actions and critical messages persist.
- Services can trigger global feedback.
- Rerenders never repeat speech.
Helpful before you start
- Component state
- Timeout cleanup
- ARIA live regions
What good looks like in a 10-minute first pass
Must cover
- One global owner orders the visible stack and overflow queue.
- Dismiss and timeout share cleanup; only the first changes state.
Strong signals
- Actionable messages persist; the viewport owns no timers.
- Separate announcement identity prevents repeat speech after rerenders.
Stretch if time
Route scope, pause/resume, and measured fair overflow.
Avoid
Each component owns timers or announces again on every render.
Need a hint? Three decisions to make
- Choose the owner of global commands, ordered records, and rendering.
- Define one timer lifecycle that resolves dismiss-timeout races safely.
- Separate visible stacking policy from accessible announcement policy.
Guided mock requires a tablet or desktop viewport of at least 768px. It will not start automatically.
Requirements
- Open with the user storyA user saves a profile and sees Profile saved without losing focus. A later edit conflict shows Review or Undo and must remain available until it is resolved or safely dismissed. Either event may come from a component or a service, so callers need one global command API rather than a toast container on every page. This gives the design two concrete records to follow instead of starting with an abstract notification platform.
- Set the boundaryKeep the scope on the client feedback layer. It accepts typed commands, keeps at most three records visible, and puts additional distinct records into an ordered overflow queue. Low-risk status messages may expire. Actionable and critical messages are persistent because a timer must not remove the user’s only recovery path. A server owns the profile save or conflict itself; the toast only reports its result and exposes a command such as Review.
- Assign one owner per jobThe store owns normalized records plus the visible and queued order. A lifecycle coordinator owns every expiry handle and all cleanup. The viewport renders the store through one root portal, applies the three-item limit, and sends dismiss or action intent back; it never starts a timer. A separate announcer tracks announcement identity and writes eligible messages to a stable live region once, so visual rerenders do not repeat speech.
- Resolve the required raceManual dismiss and timeout both request remove(toastId, cause). The first request marks that ID removed, tells the lifecycle coordinator to clear its handle, and lets the store promote the next queued record. The second request reads the terminal state and does nothing. Cleanup is therefore idempotent: there is one removal transition, one timer disposal, and no late callback that can delete a newer toast or mutate an unmounted viewport.
- Close with observable behaviorA burst never renders more than three toasts, but distinct results remain ordered in the queue. The viewport becomes full-width on a narrow screen without changing record identity. Ordinary feedback uses a polite status announcement; genuinely urgent feedback may use an alert. Appearance never steals focus, action controls remain keyboard reachable, and the announcer suppresses a repeated ID even when React rerenders, reorders, or moves its visual element.
- Can services outside the component tree trigger feedback?
- Which outcomes are safe to expire, and which expose an action?
- Is three the product’s visible-stack limit on every breakpoint?
- Which events have a stable key that makes duplicate collapse safe?
- One root viewport renders non-blocking feedback.
- The ordered overflow queue does not lose a distinct outcome.
- Persistent actions never inherit a generic expiry.
- One eligible message produces at most one live-region update.
Architecture
| Owner | Keeps | Does not own |
|---|---|---|
| Toast store | Normalized records, visible order, overflow order, and terminal removal state. | Browser timeout handles or DOM nodes. |
| Lifecycle coordinator | One expiry handle per timed record and idempotent disposal by ID. | Visual stacking, focus, or live-region text. |
| Toast viewport | A portal view of at most three records plus keyboard-operable actions. | Independent timers, copied records, or announcement history. |
| Announcer | A stable live region and a set of announcement IDs already emitted. | Toast lifetime or visual placement. |
- Accept the commandA feature or service calls toast.success('Profile saved', { dedupeKey: 'profile-save:42' }). The API validates the options and sends an add command to the store.
- Admit the recordThe store assigns a stable ID. If fewer than three records are visible it appends the record there; otherwise it appends the distinct record to the ordered queue.
- Schedule and renderThe lifecycle coordinator schedules the expiry for this timed status. The viewport renders the same record through the root portal, while the announcer publishes Profile saved only if that ID has not been announced.
- Converge removalDismiss and timeout submit the same ID. The store accepts the first transition, the coordinator clears the handle, and a queued record may become visible. Any late signal is a no-op.
| Event | State change | User-visible result |
|---|---|---|
| Profile save succeeds | Insert one polite, timed status record. | Profile saved appears without moving focus. |
| The same success event repeats | Match its safe dedupe key instead of inserting another record. | The sentence is not duplicated visually or audibly. |
| An edit conflict arrives | Insert a persistent action record with a Review command. | Review remains available until resolution or safe dismissal. |
| Dismiss races with expiry | Only the first remove transition changes store state; cleanup runs once. | The toast disappears once and the next queued record is stable. |
Read diagram as text
Text fallback: A global command enters the toast store. The lifecycle coordinator schedules or cancels expiry, the viewport renders at most three records, and the announcer speaks each eligible toast ID once.
Data
type ToastVariant = 'success' | 'error' | 'warning' | 'info';
type ToastPlacement = 'top-start' | 'top-end' | 'bottom-start' | 'bottom-end';
type ToastLifetime =
| { kind: 'persistent' }
| { kind: 'timed'; durationMs: number };
type ToastActionState = 'idle' | 'pending' | 'failed';
interface ToastRecord {
id: string;
dedupeKey?: string;
variant: ToastVariant;
message: string;
description?: string;
placement: ToastPlacement;
lifetime: ToastLifetime;
urgency: 'polite' | 'urgent';
action?: {
label: string;
commandId: string;
state: ToastActionState;
};
}
interface ToastRuntime {
toastId: string;
deadline?: number;
timerHandle?: ReturnType<typeof setTimeout>;
status: 'scheduled' | 'removing' | 'disposed';
}
interface AnnouncementState {
emittedIds: Set<string>;
}
interface ToastState {
visible: ToastRecord[];
queued: ToastRecord[];
defaultPlacement: ToastPlacement;
defaultTimedDurationMs: number;
maxVisible: number;
}- AddNormalize the command into a ToastRecord with a unique ID. Put it in visible when capacity remains; otherwise append the distinct record to queued. A safe dedupe key may update an existing matching record rather than create a copy.
- ScheduleFor a timed lifetime, the lifecycle coordinator creates one ToastRuntime keyed by toastId. Persistent records have no expiry handle, so Review or Undo cannot disappear because a provider default was applied accidentally.
- RemoveThe first dismiss or timeout marks runtime removing, clears its handle, removes the record, and promotes the next queued item. A later request sees removing, disposed, or a missing ID and returns without another state change.
- AnnounceThe announcer checks emittedIds before updating its live region. It records identity outside ToastRecord, so a visual reorder, responsive move, or component rerender cannot make the same message sound new.
| State | Owner | Reason |
|---|---|---|
| visible and queued | Toast store | One ordered source drives admission and promotion. |
| ToastRuntime by toastId | Lifecycle coordinator | Browser handles are created and disposed in one place. |
| Rendered items | Toast viewport | The UI derives rows from store records instead of copying them. |
| emittedIds | Announcer | Announcement identity remains independent of the DOM. |
Interfaces
type ToastLifetimeInput =
| { kind: 'persistent' }
| { kind: 'timed'; durationMs?: number };
interface ToastOptions {
description?: string;
placement?: ToastPlacement;
lifetime?: ToastLifetimeInput;
urgency?: 'polite' | 'urgent';
dedupeKey?: string;
action?: {
label: string;
command: () => Promise<void> | void;
};
}
interface ToastPatch extends Partial<ToastOptions> {
message?: string;
}
interface ToastApi {
success(message: string, options?: ToastOptions): string;
error(message: string, options?: ToastOptions): string;
warning(message: string, options?: ToastOptions): string;
info(message: string, options?: ToastOptions): string;
update(id: string, patch: ToastPatch): void;
dismiss(id: string): void;
dismissAll(): void;
}
interface ToastProviderProps {
children: unknown;
placement?: ToastPlacement;
timedDurationMs?: number;
maxVisible?: number;
}- Describe the outcomeFeature code calls toast.success('Profile saved', { dedupeKey: 'profile-save:42', lifetime: { kind: 'timed' } }). For a conflict it supplies a Review action and persistent lifetime.
- Normalize onceThe API resolves provider defaults, validates that an action cannot inherit timed expiry, registers the action command, and sends one add command with a stable toast ID to the store.
- Render derived stateThe viewport subscribes to visible records and renders buttons whose handlers send action or dismiss intent. It never copies records into local component state and never calls setTimeout.
- Remove through one gateBoth dismiss(id) and the lifecycle coordinator’s expiry signal reach the same remove operation. The accepted transition disposes runtime before publishing the new store snapshot; a second signal for that ID returns unchanged.
| Decision | Why it is visible in the API |
|---|---|
| Return the toast ID | The caller can update progress or dismiss one known result without searching by message text. |
| Use lifetime instead of raw milliseconds alone | Persistent is an explicit semantic state, not a special duration value. |
| Accept urgency, not ARIA role | The announcer maps ordinary status to polite speech and reserves interruption for urgent outcomes. |
| Keep maxVisible on the provider | The application configures one viewport policy instead of allowing each caller to compete for space. |
Optimizations
| Failure | Response | Invariant |
|---|---|---|
| Manual dismiss and expiry fire together | Send both signals through remove(id); accept the first transition and ignore the late one. | Store removal and timeout disposal each happen once. |
| A burst exceeds the visible limit | Render three records and append additional distinct records to the ordered queue. | The viewport stays bounded without losing an outcome. |
| The same save event repeats | Collapse only records that share a safe dedupe key. | Unrelated messages and recovery actions remain distinct. |
| Review action fails | Keep the persistent toast, expose retry, and retain failure context. | The user’s recovery path remains available. |
Accessibility and responsive behavior
Verification and measurement
- Fire dismiss and expiry in both orders; assert one removal and one disposal.
- Send four distinct commands; assert three visible and one queued record.
- Rerender and reorder a record; assert its announcement ID is emitted once.
- Fail an action; assert the persistent toast keeps keyboard-operable retry.
- Use long localized text; assert wrapping does not cover controls.
Read diagram as text
Text fallback: Manual dismiss marks the toast removing and clears its timeout. A late timeout observes the terminal state and does nothing; if timeout wins, a later dismiss is also a no-op.
Use the Question Library for baseline coverage, then move into a Study Plan before targeted Company Prep.