How would you design a notification/toast system in React?
Advertisement
🧩 Scenario
🧠 Architecture Walkthrough
Context + Portal as the Foundation
The system is built on two independent React mechanisms working together. React Context provides the communication channel: any component in the tree can call useToast() to get the addToast, removeToast, and convenience methods without receiving them as props.
This is the right pattern here because toasts are triggered by deeply nested components a save button inside a form inside a modal and wiring that through props would be impractical. The second mechanism is createPortal, which renders the ToastContainer outside the React component tree and directly into document.body.
This is critical for z-index: because CSS stacking contexts are scoped to the nearest positioned ancestor, a toast rendered inside a modal's stacking context could be clipped by the modal. Portals escape that constraint entirely, so toasts always render above everything else regardless of where they are triggered from.
The Auto-Dismiss Timer and Hover Pause
Each Toast component manages its own dismiss timer using useRef rather than useState. A ref is used because updating the timer reference should not cause a re-render it is an implementation detail, not visual state.
When the component mounts, startDismissTimer sets a setTimeout that first triggers the exit animation (setIsExiting(true)) and then, after ANIMATION_DURATION ms, calls onRemove.
The two-step process is what enables the exit animation: setting isExiting changes the CSS transform and opacity immediately, and the actual DOM removal happens only after the animation has had time to complete.
On mouseenter, pauseDismissTimer clears the timeout; on mouseleave, startDismissTimer is called again with the full original duration. This means the timer always restarts from the full duration rather than the remaining time a deliberate simplification that works well for short durations like 4 seconds.
The Promise Helper Pattern
The promise method in the context value is one of the most useful patterns in the system. It accepts a Promise (or an async function) and handles the full loading-to-settled lifecycle: it shows a persistent loading toast immediately, then removes it and shows either a success or error toast depending on the outcome.
The key implementation detail is that loading returns the toast ID, and that ID is captured in loadingId. Without this reference, there would be no way to remove the correct loading toast when the promise settles.
This pattern mirrors popular libraries like react-hot-toast and is what interviewers are looking for when they ask how you would handle async operations in a toast system.
The promise method also re-throws the error after showing the error toast, which lets the caller's catch block still run its own error handling logic without the toast system swallowing the exception.
Max Toasts and the Stack Ordering Decision
The addToast function slices the toast array to maxToasts (default 5) every time a new toast is added. New toasts are prepended with [toast, ...prev], which means they appear at index 0.
In the ToastContainer, if the position is a bottom variant, the array is reversed before rendering. This keeps the visual ordering consistent: new toasts always appear closest to the anchor point (top-right corner or bottom-right corner) regardless of the array direction.
Without the reverse for bottom positions, new toasts would push older ones down rather than stacking on top of the anchor point, which feels wrong to users. The MAX_TOASTS cap prevents the screen from being overwhelmed if a buggy component fires addToast in a loop, only five toasts ever appear simultaneously.
💡 Key Code Explained
const startDismissTimer = useCallback(() => {
if (persistent || duration === 0) return;
timeoutRef.current = setTimeout(() => {
setIsExiting(true);
setTimeout(() => onRemove(id), ANIMATION_DURATION);
}, duration);
}, [id, duration, persistent, onRemove]);
The nested setTimeout is deliberate: the outer one fires after the toast's full display duration, and the inner one fires after the CSS exit animation completes. If you called onRemove(id) immediately in the outer timeout, the toast would disappear from the DOM before its exit animation could play users would see a jump rather than a slide-out.
ANIMATION_DURATION (300ms) matches the CSS transition duration in getToastStyles. These two values must stay in sync; if they drift, you get either a flash of invisible content or a visible element that is no longer interactive because its React lifecycle has ended. This is the most common bug when developers try to add animations to dynamically removed elements.
const addToast = useCallback(
(toastConfig) => {
const toast = {
id: nextId.current++,
type: ToastTypes.INFO,
duration: DEFAULT_DURATION,
persistent: false,
...toastConfig,
};
setToasts((prev) => {
const newToasts = [toast, ...prev];
return newToasts.slice(0, maxToasts);
});
return toast.id;
},
[maxToasts],
);
nextId is a ref rather than state because incrementing it should not trigger a re-render it is only used to generate unique IDs. The ID is returned from addToast so the caller can store it and remove that specific toast later.
This return value is what makes the promise helper possible: const loadingId = loading(loadingMessage) captures the ID, and removeToast(loadingId) targets exactly that toast when the promise settles.
If addToast returned void, you would have no way to programmatically dismiss a specific toast from outside the toast system itself.
return (
<ToastContext.Provider value={contextValue}>
{children}
{createPortal(
<ToastContainer
toasts={toasts}
position={position}
onRemove={removeToast}
/>,
document.body,
)}
</ToastContext.Provider>
);
The createPortal call is placed inside the Provider's render, not in a separate component. This means the portal has access to the same state (toasts, removeToast) that the context exposes.
If the container were in a sibling component, you would need to lift state or use a separate store. The placement also means only one portal is ever active the Provider is typically at the root, so the container is mounted once for the lifetime of the app.
A common mistake is creating a new portal on every render by calling createPortal inside a component that re-renders frequently, which causes the container to re-mount and animations to reset.
⚖️ Tradeoffs
| Approach | Pro | Con |
|---|---|---|
| Context + Portal (chosen approach) | Simple to use, no external dependency, tree-integrated | Context value changes re-render all consumers; high-frequency toast firing can cause performance issues |
| Event emitter (mitt, eventemitter3) | Zero re-renders in the provider, fully decoupled | No React integration; harder to test with React Testing Library; needs manual cleanup |
| Zustand or Jotai global store | Selective subscriptions prevent unnecessary re-renders | Adds a state management dependency; overkill for most apps |
| Prop drilling | No context or external state needed | Completely impractical past 2–3 nesting levels; breaks encapsulation |
| CSS-only animations | No JS animation library needed | Exit animations require keeping removed elements in the DOM temporarily, which requires JS state anyway |
🎯 What Interviewers Actually Check
- Whether you identify that
createPortalis necessary for z-index correctness, not just "to make it work globally" the stacking context explanation shows deeper understanding - Whether you can explain why the dismiss timer uses
setTimeouttwice (once for dismiss delay, once for animation duration) rather than just once - Whether you return the toast ID from
addToastand use it in thepromisehelper many candidates implementpromisebut forget that it needs a reference to the specific loading toast - Whether your
role="alert"placement is correct it belongs on the individual toast element, not the container, so screen readers announce each toast as it appears - Whether you handle the edge case where
persistent: truetoasts must not have an auto-dismiss timer, and how thestartDismissTimerfunction guards against this
❓ Follow-Up Questions
- The current hover-pause implementation always restarts the full duration when the mouse leaves. How would you change it to resume from the remaining time instead and what state or ref would you need to track for that?
- If the user's app has a
<StrictMode>wrapper in development, effects run twice. How does the current timer implementation behave in StrictMode, and does it cause any observable bugs? - How would you write a test that verifies a toast auto-dismisses after 4 seconds without actually waiting 4 seconds in the test?
- If 100 toasts are fired in rapid succession (e.g., a polling loop gone wrong), the current system caps at 5 visible but still processes all 100 state updates. How would you add a server-side or debounced guard to prevent this at the call site?
- Your designer says toasts should stack with a "fan" effect each toast slightly offset and scaled down rather than a linear list. What CSS changes would make this work, and what data would the container need about each toast's position in the stack?
🎮 Live Demo
📝 Summary
A toast system that works correctly at scale is built from two independent mechanisms: Context provides the communication channel between deeply nested callers and the toast store, while createPortal ensures the rendered output sits in a clean stacking context at the document root.
The most subtle implementation detail is the two-stage dismiss: triggering the exit animation first, then removing the element from the DOM after the animation completes, which prevents the visual jump that occurs when elements disappear instantaneously.
The promise helper pattern showing a loading toast, capturing its ID, and swapping it for the final state is a small API surface that eliminates a whole category of async UX boilerplate across a codebase.
These design choices compound: each one is a tradeoff that favors correctness and usability over simplicity, and understanding why each exists is what separates a candidate who has used a toast library from one who can build one.
Why render toasts in a portal?
To keep them above other UI layers regardless of component nesting.
How do you prevent overlapping toasts?
Use a queue or stack, and apply staggered offsets with transitions.
Advertisement
Stay Updated
Get the latest frontend challenges, interview questions and tutorials delivered to your inbox.
Advertisement