Build a toast system step by step, as in an interview. A provider and a hook, a portal, a timer for each toast, pause on hover, live regions and a stable API.
A toast is a short message that shows up at a corner of the screen, like “Saved”. After a few seconds, it goes away by itself. Part 42 said that any part of an app may show one. A portal puts them all in one place. In this part we build that toast system.
This is a “build it yourself” part. In many interviews, you get a small feature to build and a list of needs. Toasts are a common task. They look small, but they use a lot of this series. There is context, a reducer, a portal, timers in Effects, refs, live regions and focus.
We build it one step at a time. Every step is a full app that you can run. Each step links back to the part that explained its idea.
Try this first
Here is a first toast system. It is close to the kata this part follows. Read it, but don’t press Run yet.
import { createContext, useContext, useState, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
type Toast = { id: number; message: string }
const ToastContext = createContext<((message: string) => void) | null>(null)
let nextId = 1
function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([])
function addToast(message: string) {
const id = nextId++
setToasts(list => [...list, { id, message }])
setTimeout(() => {
setToasts(list => list.filter(t => t.id !== id))
}, 5000)
}
return (
<ToastContext value={addToast}>
{children}
{createPortal(
<ul style={{ position: 'fixed', top: 8, right: 8, listStyle: 'none', zIndex: 1000 }}>
{toasts.map(t => (
<li key={t.id} style={{ background: '#222', color: 'white', padding: 12, marginTop: 8 }}>
{t.message}{' '}
<button onClick={() => setToasts(list => list.filter(x => x.id !== t.id))}>×</button>
</li>
))}
</ul>,
document.body,
)}
</ToastContext>
)
}
function SaveButton() {
const addToast = useContext(ToastContext)
return <button onClick={() => addToast?.('Saved')}>Save</button>
}
export default function App() {
return (
<ToastProvider>
<SaveButton />
</ToastProvider>
)
}
Make a guess. Press Save, then move the mouse onto the black toast and keep it there. You are reading it. Will it wait for you?
Now press Run and try it.
It doesn’t wait. Five seconds after the click, the toast goes, with the mouse still on it. Try the keyboard too: press Save, then press Tab until the × button has the focus. The toast still goes, and the focus goes to the page’s <body>.
Nothing here can stop the timer. addToast starts it and forgets it. Nobody keeps its ID, so nobody can clear it. That is the main problem we fix in this part. There are a few smaller ones too. The × button’s only name is the sign ×. A screen reader isn’t told about the toast at all. We’ll fix each one.
The task
Here is the task, the way an interviewer might give it:
- Any component can show a toast with
toast.success('Saved'),toast.error(...)ortoast.info(...). - Toasts show at the top right, above everything else on the page.
- A toast goes away by itself after 5 seconds. It also has a close button.
- The time stops while the mouse is on the toast, or while the focus is inside it.
- At most 3 toasts show at once. The newest is on top.
- Screen reader users hear each toast. A toast never moves the focus.
- Showing a toast must not make the rest of the app render again.
A good first move in an interview is to say these back, and to ask about what is missing. For example: “Should errors also go away by themselves?” We’ll come back to that question in step 4.
Step 1: a provider and a useToast hook
Many components need to show toasts. So the list of toasts lives in one place, in a provider, and components reach it through context. Part 24 built this shape for a cart: a reducer in a provider component, and a custom hook to read it.
The list has two rules: add a toast, and remove one. A reducer keeps those rules in one function (Part 15):
import { createContext, useContext, useMemo, useReducer, useRef, type ReactNode } from 'react'
type Kind = 'success' | 'error' | 'info'
type Toast = { id: number; kind: Kind; message: string }
type Action = { type: 'added'; toast: Toast } | { type: 'removed'; id: number }
function toastReducer(toasts: Toast[], action: Action): Toast[] {
switch (action.type) {
case 'added':
return [...toasts, action.toast]
case 'removed':
return toasts.filter(t => t.id !== action.id)
}
}
type ToastApi = Record<Kind, (message: string) => void>
const ToastContext = createContext<ToastApi | null>(null)
function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, dispatch] = useReducer(toastReducer, [])
const nextId = useRef(1)
const toast = useMemo(() => {
function add(kind: Kind, message: string) {
dispatch({ type: 'added', toast: { id: nextId.current++, kind, message } })
}
return {
success: (message: string) => add('success', message),
error: (message: string) => add('error', message),
info: (message: string) => add('info', message),
}
}, [])
return (
<ToastContext value={toast}>
{children}
<ul>
{toasts.map(t => (
<li key={t.id}>
{t.kind}: {t.message}{' '}
<button onClick={() => dispatch({ type: 'removed', id: t.id })}>Close</button>
</li>
))}
</ul>
</ToastContext>
)
}
function useToast() {
const toast = useContext(ToastContext)
if (toast === null) {
throw new Error('useToast must be used inside a ToastProvider')
}
return toast
}
function Editor() {
const toast = useToast()
return (
<div>
<button onClick={() => toast.success('Saved')}>Save</button>
<button onClick={() => toast.error('Could not delete')}>Delete</button>
</div>
)
}
export default function App() {
return (
<ToastProvider>
<Editor />
</ToastProvider>
)
}
Run it. Press Save, then Delete. Two toasts appear under the buttons. Press the first Close, and “Saved” goes.
There is no portal and no timer yet. Those come next. Here is what this step gives us:
- One way in. Components call
useToast()and get an object with three functions:toast.success,toast.errorandtoast.info. They never see the list, the reducer or the context. - A clear error. The context’s default is
null. If a component callsuseToast()with no provider above it, the hook throws. Part 24 did the same withuseCart(). Press Edit, and inAppput<Editor />outside<ToastProvider>, inside a Fragment. Run it. The app stops with this message:
useToast must be used inside a ToastProvider
- An ID for every toast. We’ll need it to remove the right toast. The next ID lives in a ref (Part 14). Changing it doesn’t need a new render, and each provider gets its own counter. The ID is made inside
add, which runs when a button is clicked. It is not made inside the reducer. Practice question 2 shows why.
useMemo keeps the same toast object for as long as the provider is on the page. Part 21 explained useMemo. Its list of dependencies is empty, []. That is safe because dispatch never changes, as Part 15 showed, and nextId is a ref. Step 7 measures what this buys us.
Step 2: draw the toasts in a portal
Toasts must show above everything. In step 1, the list sits inside the app, under the buttons. Its parents decide where it can be drawn. Part 42 showed how a parent can cut things off, and the fix: createPortal. It draws the list in document.body, but the list stays a child of the provider in React’s tree.
import { createContext, useContext, useMemo, useReducer, useRef, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
type Kind = 'success' | 'error' | 'info'
type Toast = { id: number; kind: Kind; message: string }
type Action = { type: 'added'; toast: Toast } | { type: 'removed'; id: number }
const COLORS: Record<Kind, string> = { success: '#166534', error: '#991b1b', info: '#1e40af' }
function toastReducer(toasts: Toast[], action: Action): Toast[] {
switch (action.type) {
case 'added':
return [...toasts, action.toast]
case 'removed':
return toasts.filter(t => t.id !== action.id)
}
}
type ToastApi = Record<Kind, (message: string) => void>
const ToastContext = createContext<ToastApi | null>(null)
function useToast() {
const toast = useContext(ToastContext)
if (toast === null) {
throw new Error('useToast must be used inside a ToastProvider')
}
return toast
}
function ToastItem({ toast, onClose }: { toast: Toast; onClose: (id: number) => void }) {
return (
<li style={{ marginTop: 8, padding: 12, borderRadius: 6, color: 'white', background: COLORS[toast.kind] }}>
{toast.message}{' '}
<button onClick={() => onClose(toast.id)}>×</button>
</li>
)
}
function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, dispatch] = useReducer(toastReducer, [])
const nextId = useRef(1)
const toast = useMemo(() => {
function add(kind: Kind, message: string) {
dispatch({ type: 'added', toast: { id: nextId.current++, kind, message } })
}
return {
success: (message: string) => add('success', message),
error: (message: string) => add('error', message),
info: (message: string) => add('info', message),
}
}, [])
return (
<ToastContext value={toast}>
{children}
{createPortal(
<ul style={{ position: 'fixed', top: 8, right: 8, width: 240, margin: 0, padding: 0, listStyle: 'none', zIndex: 1000 }}>
{toasts.map(t => (
<ToastItem key={t.id} toast={t} onClose={id => dispatch({ type: 'removed', id })} />
))}
</ul>,
document.body,
)}
</ToastContext>
)
}
function Editor() {
const toast = useToast()
return (
<div>
<button onClick={() => toast.success('Saved')}>Save</button>
<button onClick={() => toast.info('3 new messages')}>Check mail</button>
<button onClick={() => toast.error('Could not delete')}>Delete</button>
</div>
)
}
export default function App() {
return (
<div style={{ paddingTop: 200 }}>
<div style={{ transform: 'translateX(0)', overflow: 'hidden', height: 60, border: '1px solid gray' }}>
<ToastProvider>
<Editor />
</ToastProvider>
</div>
</div>
)
}
Run it and press all three buttons. The toasts show at the top right of the result box, in three colors.
Look at App. The whole app, provider and all, sits in a box with transform and overflow: hidden. Apps do this, for example to slide a panel in. Part 42 showed why that matters. A transform makes the box the starting point, even for position: fixed. Then overflow: hidden cuts off what sticks out. The paddingTop only makes room for the toasts above the box, in the playground’s small result box.
We measured both ways, in three browsers: Chromium, Firefox and WebKit, the engine of Safari. With the portal, all three toasts showed at the top right. Then we pressed Edit and took createPortal out, so the <ul> was drawn inside the box. Now the toasts were placed from the box, not the window. The second and third toasts could not be seen at all. To try it, press Edit. Delete createPortal( before the <ul>. After </ul>, delete , document.body, and the ) that closes it.
The list also has zIndex: 1000. A zIndex only counts inside its group, called a stacking context (Part 42). In <body>, the toasts are compared with the page’s top-level boxes, not trapped inside some group.
We checked where the toasts went. After two clicks, the app’s own box still held only the three buttons. The <ul> with both toasts was the last child of <body>.
Two small changes came with the portal. Each toast is now its own component, ToastItem. And the provider gives it an onClose function, instead of dispatch. Both matter in the next step.
Step 3: a timer for each toast
Now each toast must go away after 5 seconds. Where should the timer live?
“Try this first” started it in addToast, and forgot it. But a timer belongs to one toast. It should start when that toast shows, and stop when it leaves the page. That is what an Effect with a cleanup does. Part 12 showed it with setInterval, and setTimeout works the same way. So the timer goes in ToastItem.
Here is a first try. It has a bug. To see it, close prints a line each time it runs.
import { createContext, useCallback, useContext, useEffect, useMemo, useReducer, useRef, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
type Kind = 'success' | 'error' | 'info'
type Toast = { id: number; kind: Kind; message: string }
type Action = { type: 'added'; toast: Toast } | { type: 'removed'; id: number }
const TIME = 5000
const COLORS: Record<Kind, string> = { success: '#166534', error: '#991b1b', info: '#1e40af' }
function toastReducer(toasts: Toast[], action: Action): Toast[] {
switch (action.type) {
case 'added':
return [...toasts, action.toast]
case 'removed':
return toasts.filter(t => t.id !== action.id)
}
}
type ToastApi = Record<Kind, (message: string) => void>
const ToastContext = createContext<ToastApi | null>(null)
function useToast() {
const toast = useContext(ToastContext)
if (toast === null) {
throw new Error('useToast must be used inside a ToastProvider')
}
return toast
}
function ToastItem({ toast, onClose }: { toast: Toast; onClose: (id: number) => void }) {
useEffect(() => {
setTimeout(() => onClose(toast.id), TIME)
}, [toast.id, onClose])
return (
<li style={{ marginTop: 8, padding: 12, borderRadius: 6, color: 'white', background: COLORS[toast.kind] }}>
{toast.message}{' '}
<button onClick={() => onClose(toast.id)}>×</button>
</li>
)
}
function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, dispatch] = useReducer(toastReducer, [])
const nextId = useRef(1)
const toast = useMemo(() => {
function add(kind: Kind, message: string) {
dispatch({ type: 'added', toast: { id: nextId.current++, kind, message } })
}
return {
success: (message: string) => add('success', message),
error: (message: string) => add('error', message),
info: (message: string) => add('info', message),
}
}, [])
const close = useCallback((id: number) => {
console.log('close', id)
dispatch({ type: 'removed', id })
}, [])
return (
<ToastContext value={toast}>
{children}
{createPortal(
<ul style={{ position: 'fixed', top: 8, right: 8, width: 240, margin: 0, padding: 0, listStyle: 'none', zIndex: 1000 }}>
{toasts.map(t => <ToastItem key={t.id} toast={t} onClose={close} />)}
</ul>,
document.body,
)}
</ToastContext>
)
}
function Editor() {
const toast = useToast()
return <button onClick={() => toast.success('Saved')}>Save</button>
}
export default function App() {
return (
<ToastProvider>
<div style={{ paddingTop: 200 }}>
<Editor />
</div>
</ToastProvider>
)
}
close 1
close 1
Run it, press Save once, and wait. After 5 seconds the toast goes, and the Console shows close 1 two times.
Strict Mode did this. On mount, in development, it runs each Effect’s setup, then its cleanup, then the setup again (Part 12). Our Effect has no cleanup. So the test run left one timer behind, and two timers ran out. We counted with fake timers in jsdom: 2 timers started and 0 were cleared. With Strict Mode off, the same app logged close 1 once.
So is it only a development problem? No. Run it again, press Save, and press × after 1 second. The toast goes, and close 1 prints. Then, at 5 seconds, close 1 prints twice more: both timers still ran. The playground always uses Strict Mode. We checked without it, as in production: there was one timer, and close 1 printed once more at 5 seconds. A timer of a toast that is already gone still ran. Here it does no harm, because there is no toast 1 left to remove. But a toast with an “on close” step would run that step twice. Strict Mode only made the missing cleanup easy to see.
The fix is one line, the cleanup:
import { createContext, useCallback, useContext, useEffect, useMemo, useReducer, useRef, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
type Kind = 'success' | 'error' | 'info'
type Toast = { id: number; kind: Kind; message: string }
type Action = { type: 'added'; toast: Toast } | { type: 'removed'; id: number }
const TIME = 5000
const COLORS: Record<Kind, string> = { success: '#166534', error: '#991b1b', info: '#1e40af' }
function toastReducer(toasts: Toast[], action: Action): Toast[] {
switch (action.type) {
case 'added':
return [...toasts, action.toast]
case 'removed':
return toasts.filter(t => t.id !== action.id)
}
}
type ToastApi = Record<Kind, (message: string) => void>
const ToastContext = createContext<ToastApi | null>(null)
function useToast() {
const toast = useContext(ToastContext)
if (toast === null) {
throw new Error('useToast must be used inside a ToastProvider')
}
return toast
}
function ToastItem({ toast, onClose }: { toast: Toast; onClose: (id: number) => void }) {
useEffect(() => {
const timer = setTimeout(() => onClose(toast.id), TIME)
return () => clearTimeout(timer)
}, [toast.id, onClose])
return (
<li style={{ marginTop: 8, padding: 12, borderRadius: 6, color: 'white', background: COLORS[toast.kind] }}>
{toast.message}{' '}
<button onClick={() => onClose(toast.id)}>×</button>
</li>
)
}
function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, dispatch] = useReducer(toastReducer, [])
const nextId = useRef(1)
const toast = useMemo(() => {
function add(kind: Kind, message: string) {
dispatch({ type: 'added', toast: { id: nextId.current++, kind, message } })
}
return {
success: (message: string) => add('success', message),
error: (message: string) => add('error', message),
info: (message: string) => add('info', message),
}
}, [])
const close = useCallback((id: number) => {
console.log('close', id)
dispatch({ type: 'removed', id })
}, [])
return (
<ToastContext value={toast}>
{children}
{createPortal(
<ul style={{ position: 'fixed', top: 8, right: 8, width: 240, margin: 0, padding: 0, listStyle: 'none', zIndex: 1000 }}>
{toasts.map(t => <ToastItem key={t.id} toast={t} onClose={close} />)}
</ul>,
document.body,
)}
</ToastContext>
)
}
function Editor() {
const toast = useToast()
return <button onClick={() => toast.success('Saved')}>Save</button>
}
export default function App() {
return (
<ToastProvider>
<div style={{ paddingTop: 200 }}>
<Editor />
</div>
</ToastProvider>
)
}
close 1
Run it and press Save. Now close 1 prints once. Strict Mode still started 2 timers, but the cleanup cleared the first one at once. We checked the × button too: after a click on ×, close 1 printed once, and the timer never ran. When the toast leaves the page, React runs the cleanup, and clearTimeout stops its timer.
Why close uses useCallback
The Effect lists onClose as a dependency. If onClose were a new function on every render, the Effect would run again on every render. Its cleanup would clear the timer, and its setup would start a new 5-second one.
That is what happens if you write onClose={id => dispatch({ type: 'removed', id })} in the list, as in step 2. Every new toast renders the provider, so every older toast starts its 5 seconds again. We measured it, with Strict Mode off. Toast 1 came at 0 seconds and toast 2 at 3 seconds. Toast 1 left at 8 seconds, not at 5. Then we pressed Save every 4 seconds. After 6 presses, all 6 toasts were still on the screen. None had left.
useCallback keeps close the same function on every render (Part 21). Its dependency list is empty, because dispatch never changes. With close, toast 1 left at 5 seconds.
Step 4: pause on hover and on focus
A toast that leaves while you read it is a problem. A toast that leaves while the focus is on its close button is worse. So the timer must stop while the mouse is on the toast, or while the focus is inside it.
A setTimeout can’t be paused. You can only clear it. So to pause, we clear the timer and remember how much time was left. To go on, we start a new timer with only the time that was left.
A paused timer is a cleared timer plus the time left. Press play, or step through it.
Here are the same steps in words. The times come from our measured run, with Strict Mode off.
- You press Save at 0 seconds. The toast shows, and its Effect starts a 5-second timer.
- The timer runs. At 2 seconds, 3 seconds are left.
- At 2 seconds the mouse moves onto the toast.
hoveredbecomestrue, so the Effect’s cleanup runs. It clears the timer and saves the time left: 3 seconds. - At 6 seconds the mouse leaves. The Effect runs again and starts a new timer, for the 3 seconds that were left.
- At 9 seconds the timer runs out.
onCloseremoves the toast, and React removesToastItemfrom the page.
An everyday example
Think of a timer for cooking that you can pause. You press pause, and the number on it stops. You press start, and it goes on from that number. It doesn’t go back to the start.
The exact version
A browser timer has no pause button. Our “pause” is a cleared timer plus a saved number, in a ref. “Go on” is a brand new timer for that number. We measure the time with Date.now(), which gives the time now, in milliseconds. A browser can also run a timer late. MDN’s setTimeout page says: “The actual delay may be longer than set”. We tried the same moves five times in three browsers, with real timers. We timed them inside the page. In Chromium and WebKit, the toast left within 12 milliseconds of the time we worked out. In Firefox, it was up to 193 milliseconds late.
Here is ToastItem with the pause. It also has a second change: error toasts don’t go away by themselves. The reason for that comes right after the example.
import { createContext, useCallback, useContext, useEffect, useMemo, useReducer, useRef, useState, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
type Kind = 'success' | 'error' | 'info'
type Toast = { id: number; kind: Kind; message: string }
type Action = { type: 'added'; toast: Toast } | { type: 'removed'; id: number }
const TIME = 5000
const COLORS: Record<Kind, string> = { success: '#166534', error: '#991b1b', info: '#1e40af' }
function toastReducer(toasts: Toast[], action: Action): Toast[] {
switch (action.type) {
case 'added':
return [...toasts, action.toast]
case 'removed':
return toasts.filter(t => t.id !== action.id)
}
}
type ToastApi = Record<Kind, (message: string) => void>
const ToastContext = createContext<ToastApi | null>(null)
function useToast() {
const toast = useContext(ToastContext)
if (toast === null) {
throw new Error('useToast must be used inside a ToastProvider')
}
return toast
}
function ToastItem({ toast, onClose }: { toast: Toast; onClose: (id: number) => void }) {
const [hovered, setHovered] = useState(false)
const [focused, setFocused] = useState(false)
const timeLeft = useRef(TIME)
const waits = toast.kind === 'error' || hovered || focused
useEffect(() => {
if (waits) return
const startedAt = Date.now()
const timer = setTimeout(() => onClose(toast.id), timeLeft.current)
return () => {
clearTimeout(timer)
timeLeft.current -= Date.now() - startedAt
}
}, [waits, toast.id, onClose])
return (
<li
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
style={{ marginTop: 8, padding: 12, borderRadius: 6, color: 'white', background: COLORS[toast.kind] }}
>
{toast.message}{' '}
<button onClick={() => onClose(toast.id)}>×</button>
</li>
)
}
function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, dispatch] = useReducer(toastReducer, [])
const nextId = useRef(1)
const toast = useMemo(() => {
function add(kind: Kind, message: string) {
dispatch({ type: 'added', toast: { id: nextId.current++, kind, message } })
}
return {
success: (message: string) => add('success', message),
error: (message: string) => add('error', message),
info: (message: string) => add('info', message),
}
}, [])
const close = useCallback((id: number) => dispatch({ type: 'removed', id }), [])
return (
<ToastContext value={toast}>
{children}
{createPortal(
<ul style={{ position: 'fixed', top: 8, right: 8, width: 240, margin: 0, padding: 0, listStyle: 'none', zIndex: 1000 }}>
{toasts.map(t => <ToastItem key={t.id} toast={t} onClose={close} />)}
</ul>,
document.body,
)}
</ToastContext>
)
}
function Editor() {
const toast = useToast()
return (
<div>
<button onClick={() => toast.success('Saved')}>Save</button>
<button onClick={() => toast.error('Could not delete')}>Delete</button>
</div>
)
}
export default function App() {
return (
<ToastProvider>
<div style={{ paddingTop: 200 }}>
<Editor />
</div>
</ToastProvider>
)
}
Run it. Press Save, and keep the mouse on the toast for a while. It stays. Move the mouse away, and it goes a few seconds later. Press Delete. The red toast stays until you press its ×.
A few details:
- Two flags, not one.
hoveredandfocusedare separate. With one flag, moving the mouse away would start the timer, even with the focus still on ×. We checked: the mouse came and went while the focus stayed on ×, and the toast was still there 20 seconds later. With one flag, it left at 7 seconds. - Focus events bubble in React.
onFocuson the<li>runs when the button inside it gets the focus. React’s docs say it plainly: in React, theonFocusevent “bubbles”. The same is true foronBlur. So one handler on the<li>covers everything inside. - Strict Mode is fine. On mount, it runs setup, cleanup and setup. The cleanup takes away the time since the first setup, which is about 0. In our fake-timer run with Strict Mode on, the toast left at exactly 5 seconds.
- The ref keeps the time left. It is not state, because the page doesn’t show it (Part 14).
What WCAG says about toasts
WCAG is the main set of rules for accessible web pages. Its rule 2.2.1 is about time limits. Its name is “Timing Adjustable”: the user must be able to change the time. The W3C’s page that explains the rule talks about toasts by name. Its example is a toast about new email that “disappears after 5 seconds”. That is fine, it says, because users can find out about new email “through other means”. They can open their list of emails. It adds: “If the user has no other means of discovering the same information (or performing the same function)”. Then each toast must follow the rule.
So here is the first rule for toasts. A toast should not be the only place where something is said. “Saved” is fine if the page also shows that the work is saved.
When a toast is the only place, rule 2.2.1 asks for one of three things. First: “The user is allowed to turn off the time limit before encountering it”. Second, the user can make it longer, “at least ten times the length of the default setting”. Third, the user is “warned before time” runs out. Then the user is “given at least 20 seconds to extend the time limit with a simple action”. A setting that keeps toasts open until they are closed would meet the first. Pause on hover and on focus helps, but it is none of these. A screen reader user may never put the focus on the toast at all.
The W3C’s guide to the alert pattern goes further. Alerts must “not affect keyboard focus”, it says. It also says “It is also important to avoid designing alerts that disappear automatically”. It links an alert that goes too fast to rule 2.2.3, “No Timing”. That rule is at WCAG’s highest level, so it is stricter than 2.2.1. That is why our error toasts wait for the user. An error is often the only place where a problem is reported.
Step 5: at most three, newest on top
In step 4, each press on Save adds one more toast at the bottom of the stack. Ten presses make ten toasts. The task says 3 at most, with the newest on top. Both changes go in the reducer:
import { createContext, useCallback, useContext, useEffect, useMemo, useReducer, useRef, useState, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
type Kind = 'success' | 'error' | 'info'
type Toast = { id: number; kind: Kind; message: string }
type Action = { type: 'added'; toast: Toast } | { type: 'removed'; id: number }
const MAX = 3
const TIME = 5000
const COLORS: Record<Kind, string> = { success: '#166534', error: '#991b1b', info: '#1e40af' }
function addWithLimit(toasts: Toast[], toast: Toast): Toast[] {
const next = [toast, ...toasts]
if (next.length <= MAX) return next
const oldest = next.findLastIndex(t => t !== toast && t.kind !== 'error')
return oldest === -1 ? next : next.filter((_, i) => i !== oldest)
}
function toastReducer(toasts: Toast[], action: Action): Toast[] {
switch (action.type) {
case 'added':
return addWithLimit(toasts, action.toast)
case 'removed':
return toasts.filter(t => t.id !== action.id)
}
}
type ToastApi = Record<Kind, (message: string) => void>
const ToastContext = createContext<ToastApi | null>(null)
function useToast() {
const toast = useContext(ToastContext)
if (toast === null) {
throw new Error('useToast must be used inside a ToastProvider')
}
return toast
}
function ToastItem({ toast, onClose }: { toast: Toast; onClose: (id: number) => void }) {
const [hovered, setHovered] = useState(false)
const [focused, setFocused] = useState(false)
const timeLeft = useRef(TIME)
const waits = toast.kind === 'error' || hovered || focused
useEffect(() => {
if (waits) return
const startedAt = Date.now()
const timer = setTimeout(() => onClose(toast.id), timeLeft.current)
return () => {
clearTimeout(timer)
timeLeft.current -= Date.now() - startedAt
}
}, [waits, toast.id, onClose])
return (
<li
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
style={{ marginTop: 8, padding: 12, borderRadius: 6, color: 'white', background: COLORS[toast.kind] }}
>
{toast.message}{' '}
<button onClick={() => onClose(toast.id)}>×</button>
</li>
)
}
function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, dispatch] = useReducer(toastReducer, [])
const nextId = useRef(1)
const toast = useMemo(() => {
function add(kind: Kind, message: string) {
dispatch({ type: 'added', toast: { id: nextId.current++, kind, message } })
}
return {
success: (message: string) => add('success', message),
error: (message: string) => add('error', message),
info: (message: string) => add('info', message),
}
}, [])
const close = useCallback((id: number) => dispatch({ type: 'removed', id }), [])
return (
<ToastContext value={toast}>
{children}
{createPortal(
<ul style={{ position: 'fixed', top: 8, right: 8, width: 240, margin: 0, padding: 0, listStyle: 'none', zIndex: 1000 }}>
{toasts.map(t => <ToastItem key={t.id} toast={t} onClose={close} />)}
</ul>,
document.body,
)}
</ToastContext>
)
}
function Editor() {
const toast = useToast()
const count = useRef(0)
return <button onClick={() => toast.info(`Message ${++count.current}`)}>Add a message</button>
}
export default function App() {
return (
<ToastProvider>
<div style={{ paddingTop: 200 }}>
<Editor />
</div>
</ToastProvider>
)
}
Run it and press “Add a message” 4 times, quickly. You see Message 4, 3 and 2, from the top down. Message 1 is gone.
addWithLimitputs the new toast first,[toast, ...toasts], so it shows on top.- With more than 3, it drops the oldest toast that is not an error.
findLastIndexfinds it, from the end of the list. Errors are never dropped, because they may be the only place a problem is reported. The new toast is never dropped either. - We checked: after Delete and then 3 Saves, the screen showed Saved, Saved and the error. The oldest Saved was dropped, not the older error.
- A dropped toast leaves the page, so React runs its Effect’s cleanup. Its timer is cleared. We checked: after 4 quick clicks, 3 timers were waiting, not 4.
The key matters a lot now. Each ToastItem keeps its own state, and its own timer, under its key (Part 8). New toasts go at the start of the list, so every toast’s place in the list changes. With key={t.id}, React knows which toast is which. Common mistakes shows what an index key does here.
There is a limit to this rule. If every older toast is an error, nothing can be dropped, so more than 3 show. We pressed Delete 4 times and then Save: 5 toasts were on the screen. And a dropped toast is still lost. Keeping it in a waiting list and showing it later is another way. It is a good thing to discuss in an interview.
Step 6: screen readers, focus and motion
Live regions, there from the start
A sighted user sees a toast appear. A screen reader user hears nothing, unless the text goes into a live region. Part 46 explained these: parts of the page that a screen reader watches. When their text changes, it reads the new text out loud, without moving the focus.
Part 46 also found the main rule: the region must be on the page before its text changes. MDN says: “Establish the live region before updating its content”. Part 48 kept its status line on the page for the same reason.
Our toasts break that rule. Each <li> appears with its text already in it. So we add a region that is always there, from the first render, and empty at first. When a toast is added, its text goes into the region.
Which kind of region? Part 46 and Part 48 used both:
role="status"is polite. MDN calls it “advisory information that is not important enough to justify an alert”. That fits “Saved” and “3 new messages”.role="alert"is assertive, so it may stop what the screen reader is saying. MDN says it “should only be used for information that requires the user’s immediate attention”. That fits an error, and nothing else here.
So there are two regions, one of each, both always on the page. A toast’s text goes into the one that fits its kind.
The regions are hidden from the eyes, because the visible toast already shows the text. MDN’s alert page shows this idea, with a “Visually hidden alert container”. It warns not to use display:none, “as this will hide it even from assistive technologies”. Assistive technology means tools like screen readers. So we use styles like MDN’s: a box 1 pixel wide, with overflow: hidden.
One more detail. MDN warns that the same text again “generally won’t be seen as a change”. Its advice is to clear the region for a moment, then put the text in. We do something close to it. Each message goes in its own <p key={id}>. A new ID means React takes out the old <p> and puts in a new one. It does this even when the text is the same. We checked: after two clicks on Save, the region held a new <p> element. React does both in one update, with no empty moment in between. We haven’t checked how a screen reader treats that.
Each region holds one message: the newest toast of its kind that is still on the screen. So one click that shows a success and an error fills both regions. We checked it. But one click that shows two polite toasts leaves only the second in the status region. A screen reader may never hear the first. If your app does that, keep a short waiting list of messages for the region.
Don’t move the focus
The W3C’s alert guide says alerts must not “affect keyboard focus”. MDN’s status page says: “Do not give focus to the status when its content updates.” Our toasts never call focus(). They have no autoFocus. We checked in the three browsers. We put the focus on Save and pressed Enter. The toast came, and the focus stayed on Save.
The close button needs a name. × is a sign, not a name. So the button gets aria-label="Close" (Part 46).
There are two gaps we leave open:
- The toasts come last. They are drawn at the end of
<body>. So with the keyboard, you reach them after everything else on the page. In the playground, Tab from Delete, the app’s last button, went to the newest toast’s Close button. On a real page, there may be many stops first. - Closing loses the focus. We pressed Enter on a toast’s Close button, in all three browsers. The toast went, and the focus went to the page’s
<body>. Part 47 met the same problem after a delete, and moved the focus to a good place. For toasts, one choice is the element that had the focus before the user went to the toast.
Less motion for people who ask for it
Toasts often slide in. Some people turn on a setting in their system to reduce motion. MDN’s page on prefers-reduced-motion says that motion can make some people with balance problems feel bad.
CSS can check that setting with @media (prefers-reduced-motion: reduce). A style={{ }} prop can’t hold a @media rule. So the playground example puts a small <style> tag inside the portal. In your own project, this CSS goes in a CSS file.
The code
Here is step 6. The reducer now keeps three things. There is the list. polite is the newest toast for the status region. urgent is the newest error, for the alert region. stillShown keeps a toast in a region only while it is on the screen.
import { createContext, useCallback, useContext, useEffect, useMemo, useReducer, useRef, useState, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
type Kind = 'success' | 'error' | 'info'
type Toast = { id: number; kind: Kind; message: string }
type State = { toasts: Toast[]; polite: Toast | null; urgent: Toast | null }
type Action = { type: 'added'; toast: Toast } | { type: 'removed'; id: number }
const MAX = 3
const TIME = 5000
const COLORS: Record<Kind, string> = { success: '#166534', error: '#991b1b', info: '#1e40af' }
const CSS = `
.toast { animation: toast-in 300ms ease-out; }
@keyframes toast-in { from { opacity: 0; transform: translateX(40px); } }
@media (prefers-reduced-motion: reduce) { .toast { animation: none; } }
`
const hidden = { position: 'absolute', width: 1, height: 1, overflow: 'hidden', clipPath: 'inset(50%)', whiteSpace: 'nowrap' } as const
function addWithLimit(toasts: Toast[], toast: Toast): Toast[] {
const next = [toast, ...toasts]
if (next.length <= MAX) return next
const oldest = next.findLastIndex(t => t !== toast && t.kind !== 'error')
return oldest === -1 ? next : next.filter((_, i) => i !== oldest)
}
function stillShown(toast: Toast | null, toasts: Toast[]) {
return toast && toasts.includes(toast) ? toast : null
}
function toastReducer(state: State, action: Action): State {
switch (action.type) {
case 'added': {
const toasts = addWithLimit(state.toasts, action.toast)
const isError = action.toast.kind === 'error'
return {
toasts,
polite: stillShown(isError ? state.polite : action.toast, toasts),
urgent: stillShown(isError ? action.toast : state.urgent, toasts),
}
}
case 'removed': {
const toasts = state.toasts.filter(t => t.id !== action.id)
return { toasts, polite: stillShown(state.polite, toasts), urgent: stillShown(state.urgent, toasts) }
}
}
}
type ToastApi = Record<Kind, (message: string) => void>
const ToastContext = createContext<ToastApi | null>(null)
function useToast() {
const toast = useContext(ToastContext)
if (toast === null) {
throw new Error('useToast must be used inside a ToastProvider')
}
return toast
}
function ToastItem({ toast, onClose }: { toast: Toast; onClose: (id: number) => void }) {
const [hovered, setHovered] = useState(false)
const [focused, setFocused] = useState(false)
const timeLeft = useRef(TIME)
const waits = toast.kind === 'error' || hovered || focused
useEffect(() => {
if (waits) return
const startedAt = Date.now()
const timer = setTimeout(() => onClose(toast.id), timeLeft.current)
return () => {
clearTimeout(timer)
timeLeft.current -= Date.now() - startedAt
}
}, [waits, toast.id, onClose])
return (
<li
className="toast"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
style={{ marginTop: 8, padding: 12, borderRadius: 6, color: 'white', background: COLORS[toast.kind] }}
>
{toast.message}{' '}
<button aria-label="Close" onClick={() => onClose(toast.id)}>×</button>
</li>
)
}
function ToastProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(toastReducer, { toasts: [], polite: null, urgent: null })
const nextId = useRef(1)
const toast = useMemo(() => {
function add(kind: Kind, message: string) {
dispatch({ type: 'added', toast: { id: nextId.current++, kind, message } })
}
return {
success: (message: string) => add('success', message),
error: (message: string) => add('error', message),
info: (message: string) => add('info', message),
}
}, [])
const close = useCallback((id: number) => dispatch({ type: 'removed', id }), [])
const { polite, urgent } = state
return (
<ToastContext value={toast}>
{children}
{createPortal(
<div>
<style>{CSS}</style>
<div role="status" style={hidden}>
{polite && <p key={polite.id}>{polite.message}</p>}
</div>
<div role="alert" style={hidden}>
{urgent && <p key={urgent.id}>{urgent.message}</p>}
</div>
<ul style={{ position: 'fixed', top: 8, right: 8, width: 240, margin: 0, padding: 0, listStyle: 'none', zIndex: 1000 }}>
{state.toasts.map(t => <ToastItem key={t.id} toast={t} onClose={close} />)}
</ul>
</div>,
document.body,
)}
</ToastContext>
)
}
function Editor() {
const toast = useToast()
return (
<div>
<button onClick={() => toast.success('Saved')}>Save</button>
<button onClick={() => toast.info('3 new messages')}>Check mail</button>
<button onClick={() => toast.error('Could not delete')}>Delete</button>
</div>
)
}
export default function App() {
return (
<ToastProvider>
<div style={{ paddingTop: 200 }}>
<Editor />
</div>
</ToastProvider>
)
}
Run it. The toasts slide in from the right. The rest of what changed, you can’t see.
We checked step 6 in jsdom and in the three browsers:
- Before any toast, both regions were on the page, and both were empty.
- After Save, the status region held
<p>Saved</p>, and the alert region was empty. - After Delete, the alert region held
<p>Could not delete</p>. The status region still held “Saved”, because that toast was still on the screen.
These we checked in jsdom only:
- After a click on the error’s ×, the alert region was empty again. That is the
removedcase andstillShown. - Each Close button had the name “Close”.
- React printed no warnings for the
<style>tag.
And this in the browsers only: the toast’s CSS animation was toast-in. With reduced motion turned on, it was none.
What we can’t check is what a screen reader says, or when. Part 46 explained why. MDN warns that live regions can “vary across browser and assistive technology combinations”. Test with a real screen reader before you ship. Part 50 shows how a test can check that the region is there and that its text changes.
There is a cost too. While a toast is on the screen, its text is on the page twice. It is in the toast, and in the hidden region. A screen reader user who reads the whole page meets it twice.
Step 7: keep toast the same object
The last need: showing a toast must not make the rest of the app render again.
Every toast changes the provider’s state, twice: once when it comes, once when it goes. Each time, the provider renders. Its children came from App, so React can skip them (Part 2). But a component that reads a context still renders when the context’s value changes (Part 23). Every component that calls useToast() reads our context.
That is why toast is made with useMemo, with an empty list. It is the same object on every render, so the context’s value never changes. Here is the whole system, with one log in Editor:
import { createContext, useCallback, useContext, useEffect, useMemo, useReducer, useRef, useState, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
type Kind = 'success' | 'error' | 'info'
type Toast = { id: number; kind: Kind; message: string }
type State = { toasts: Toast[]; polite: Toast | null; urgent: Toast | null }
type Action = { type: 'added'; toast: Toast } | { type: 'removed'; id: number }
const MAX = 3
const TIME = 5000
const COLORS: Record<Kind, string> = { success: '#166534', error: '#991b1b', info: '#1e40af' }
const CSS = `
.toast { animation: toast-in 300ms ease-out; }
@keyframes toast-in { from { opacity: 0; transform: translateX(40px); } }
@media (prefers-reduced-motion: reduce) { .toast { animation: none; } }
`
const hidden = { position: 'absolute', width: 1, height: 1, overflow: 'hidden', clipPath: 'inset(50%)', whiteSpace: 'nowrap' } as const
function addWithLimit(toasts: Toast[], toast: Toast): Toast[] {
const next = [toast, ...toasts]
if (next.length <= MAX) return next
const oldest = next.findLastIndex(t => t !== toast && t.kind !== 'error')
return oldest === -1 ? next : next.filter((_, i) => i !== oldest)
}
function stillShown(toast: Toast | null, toasts: Toast[]) {
return toast && toasts.includes(toast) ? toast : null
}
function toastReducer(state: State, action: Action): State {
switch (action.type) {
case 'added': {
const toasts = addWithLimit(state.toasts, action.toast)
const isError = action.toast.kind === 'error'
return {
toasts,
polite: stillShown(isError ? state.polite : action.toast, toasts),
urgent: stillShown(isError ? action.toast : state.urgent, toasts),
}
}
case 'removed': {
const toasts = state.toasts.filter(t => t.id !== action.id)
return { toasts, polite: stillShown(state.polite, toasts), urgent: stillShown(state.urgent, toasts) }
}
}
}
type ToastApi = Record<Kind, (message: string) => void>
const ToastContext = createContext<ToastApi | null>(null)
function useToast() {
const toast = useContext(ToastContext)
if (toast === null) {
throw new Error('useToast must be used inside a ToastProvider')
}
return toast
}
function ToastItem({ toast, onClose }: { toast: Toast; onClose: (id: number) => void }) {
const [hovered, setHovered] = useState(false)
const [focused, setFocused] = useState(false)
const timeLeft = useRef(TIME)
const waits = toast.kind === 'error' || hovered || focused
useEffect(() => {
if (waits) return
const startedAt = Date.now()
const timer = setTimeout(() => onClose(toast.id), timeLeft.current)
return () => {
clearTimeout(timer)
timeLeft.current -= Date.now() - startedAt
}
}, [waits, toast.id, onClose])
return (
<li
className="toast"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
style={{ marginTop: 8, padding: 12, borderRadius: 6, color: 'white', background: COLORS[toast.kind] }}
>
{toast.message}{' '}
<button aria-label="Close" onClick={() => onClose(toast.id)}>×</button>
</li>
)
}
function ToastProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(toastReducer, { toasts: [], polite: null, urgent: null })
const nextId = useRef(1)
const toast = useMemo(() => {
function add(kind: Kind, message: string) {
dispatch({ type: 'added', toast: { id: nextId.current++, kind, message } })
}
return {
success: (message: string) => add('success', message),
error: (message: string) => add('error', message),
info: (message: string) => add('info', message),
}
}, [])
const close = useCallback((id: number) => dispatch({ type: 'removed', id }), [])
const { polite, urgent } = state
return (
<ToastContext value={toast}>
{children}
{createPortal(
<div>
<style>{CSS}</style>
<div role="status" style={hidden}>
{polite && <p key={polite.id}>{polite.message}</p>}
</div>
<div role="alert" style={hidden}>
{urgent && <p key={urgent.id}>{urgent.message}</p>}
</div>
<ul style={{ position: 'fixed', top: 8, right: 8, width: 240, margin: 0, padding: 0, listStyle: 'none', zIndex: 1000 }}>
{state.toasts.map(t => <ToastItem key={t.id} toast={t} onClose={close} />)}
</ul>
</div>,
document.body,
)}
</ToastContext>
)
}
function Editor() {
const toast = useToast()
console.log('Editor renders')
return (
<div>
<button onClick={() => toast.success('Saved')}>Save</button>
<button onClick={() => toast.info('3 new messages')}>Check mail</button>
<button onClick={() => toast.error('Could not delete')}>Delete</button>
</div>
)
}
export default function App() {
return (
<ToastProvider>
<div style={{ paddingTop: 200 }}>
<Editor />
</div>
</ToastProvider>
)
}
Editor renders
Editor renders
Run it, and press all three buttons. Editor renders shows twice, from the first render. That is Strict Mode, which renders each component twice in development (Part 2). The clicks added nothing.
Now press Edit and remove useMemo. Change const toast = useMemo(() => { to const toast = (() => {. At the end of that block, change }, []) to })(). Now the object is made again on every render. Run it and press the buttons again. Each press logs Editor renders two more times.
We counted with fake timers in jsdom. We pressed the three buttons, then waited until the two toasts that can leave had left. That is 5 changes to the provider’s state: 3 toasts in, 2 out.
toast made with |
Editor renders after the 5 changes (Strict Mode off) |
(Strict Mode on) |
|---|---|---|
useMemo(..., []) |
0 | 0 |
| a new object on every render | 5 | 10 |
One Editor rendering 5 more times is nothing. But in a real app, many components call useToast(): forms, buttons, menus. Without useMemo, every one of them renders when any toast comes or goes.
Part 24 measured a case like this. There the provider rendered because its parent did. Here it renders from its own state. The rule is the same: useMemo on a context value helps when something else stops the render on the way down. Here that is children: they come from App, so they don’t render with the provider. The context is the only way left for a render to reach Editor, and useMemo keeps it closed.
What if a component needs the list?
Our API gives only functions. Nobody outside the provider can read the list of toasts. Say a header wants to show “3 messages”. If you put the list into the same context, every useToast() caller renders on every toast again.
Keep them apart instead. Part 24 used two contexts: one for the state, one for dispatch. Here that would be one for the list, and one for toast. A component that needs only part of the list can use a selector, as in Part 25.
What an interviewer looks for
In a task like this, working code is only part of the answer. An interviewer also listens for these:
- Questions first. How long does a toast stay? Do errors go away? How many show at once?
- The state. A list of toasts with IDs, and its rules in a reducer.
- Timers that get cleared. Each toast’s timer is cleared when it closes, when it is dropped, and when the provider goes away. We checked the last one: with two toasts waiting, we removed the whole app, and 0 timers were left.
- Strict Mode. Why
close 1printed twice, and why the fix is a cleanup. - Keys, accessibility and a stable API, as in steps 5, 6 and 7.
- Tests. A test can use fake timers, so 5 seconds pass at once. We did that by hand for this post. Vitest has
vi.useFakeTimers()andvi.advanceTimersByTime(). Its docs say that with fake timers,Date.nowgives fake times too. Our pause code needs that. Part 49 covers component tests.
Follow-up: a toast for work that takes time
“Show ‘Saving…’ while we save, then ‘Saved’ or ‘Could not save’.” We’ll call it a promise toast. A promise is JavaScript’s object for work that ends later.
You need one new action that changes a toast in place, and a new kind, loading. Here is a short version of the reducer. It leaves out step 6’s limit and live regions:
type Kind = 'success' | 'error' | 'info' | 'loading'
type Toast = { id: number; kind: Kind; message: string }
type Action =
| { type: 'added'; toast: Toast }
| { type: 'updated'; id: number; kind: Kind; message: string }
| { type: 'removed'; id: number }
function toastReducer(toasts: Toast[], action: Action): Toast[] {
switch (action.type) {
case 'added':
return [action.toast, ...toasts]
case 'updated':
return toasts.map(t => (t.id === action.id ? { ...t, kind: action.kind, message: action.message } : t))
case 'removed':
return toasts.filter(t => t.id !== action.id)
}
}
The toast keeps its ID, so React keeps the same ToastItem. A loading toast must wait, like an error, so waits checks for it too. When the kind becomes success, waits is false, and the Effect starts the timer. In step 6’s reducer, updated also puts the changed toast in polite or urgent. Then the new text is read out.
We built this on top of step 6, with a pretend server that answers after 2 seconds. At 0 seconds, the toast said “Saving…” and the status region held it. At 2 seconds, both said “Saved”. The toast left at 7 seconds: 2 seconds of work, then 5 seconds.
Follow-up: an Undo button
“After a delete, show ‘Deleted’ with an Undo button.” Now the toast holds an action, not only words. Remember the WCAG page. A toast with a time limit is fine only if the user can get the same thing elsewhere. It says “the same information (or performing the same function)”. If Undo exists only in the toast, the toast must not leave by itself. Or there must be another way to undo, like a trash folder. And the button must be easy to reach with the keyboard, which brings back the gap from step 6.
Follow-up: other corners
“Let the app choose the corner.” Add a position prop to ToastProvider, like 'bottom-left'. It sets top or bottom, and left or right, on the <ul>. At the bottom, the newest toast should be closest to the edge. So the list is drawn in the other order.
Follow-up: toasts from outside React
“Show a toast from a plain function, like a helper that loads data”. Hooks run only inside components and other hooks (Part 16). So the list moves out of React, into a small store with its own subscribe. React reads it with useSyncExternalStore. Part 25 built that kind of store.
Common mistakes
Starting the timer where the toast is added
“Try this first” called setTimeout inside addToast and kept nothing. Nothing can stop that timer: not a hover, not the focus, not the × button. In our test, the mouse rested on the toast from the first second, and it still left at 5 seconds. Start the timer in the toast’s own Effect, and clear it in the cleanup.
An index as the key
{toasts.map((t, i) => <ToastItem key={i} toast={t} onClose={close} />)}
New toasts go first, so every toast’s place changes. With an index key, the state and the timer stay with the place, not with the toast. We tried it in step 5. Message 1 came at 0 seconds, in the first place, key={0}. Message 2 came at 3 seconds. Now the ToastItem with key={0} got Message 2. Its Effect ran again, but its ref still held Message 1’s 2 seconds left. So Message 2 left at 5 seconds, after only 2 seconds on the screen. Then Message 1 moved up into place 0, where 0 milliseconds were left. It left at once, also at 5 seconds. With key={t.id}, they left at 5 and 8 seconds. Use the toast’s ID.
role="alert" on each toast
Putting role="alert" on each <li> looks easier. But then each live region arrives with its text already in it. We checked: before the first toast, no <li> had role="alert". The first one came with its text, “Saved”, already in it. MDN’s alert page warns against adding an alert that already has its text. It says that “generally does not lead to an announcement”. MDN’s live regions page is less sure: it says alerts are read “in most cases”. Part 48 met the same two pages. Either way, keep the regions on the page from the start, as in step 6.
Moving the focus to the toast
autoFocus on the close button looks helpful. We tried it: after a press on Save, the focus was on the toast’s Close button. The user was typing or clicking somewhere else, and now they are lost. The W3C’s alert guide says alerts must “not affect keyboard focus”. If the user must answer, it isn’t a toast. It is a dialog.
Practice
Press Edit on the examples above and try these.
- Take the fixed example of step 3, which prints
close 1once. Addconsole.log('start timer', toast.id)as the first line of the Effect. Change the cleanup toreturn () => { console.log('stop timer', toast.id); clearTimeout(timer) }. Press Save once and wait 5 seconds. Write down what you think the Console shows, then run it. - In step 6, make the ID inside the reducer instead. Put
let nextId = 1abovetoastReducer. In theaddedcase, addconst toast = { ...action.toast, id: nextId++ }as the first line. Then usetoastin place ofaction.toastin the rest of that case. Show the ID in each toast:#{toast.id} {toast.message}. Press Save, Save, then Check mail. Which IDs do you see? - In step 6, change
MAXto 1. Press Save, then Delete. What shows? Does “Saved” come back after the error is closed? - In step 4, delete the
onFocusandonBlurlines. Press Save, then press Tab until × has the focus, and wait. What happens to the toast, and to the focus?
Answers
- With Strict Mode on, as in the playground, five lines:
start timer 1,stop timer 1,start timer 1, and after 5 secondsclose 1andstop timer 1. The first three come at once. They are Strict Mode’s extra cleanup and setup, as a test. After 5 seconds, the timer runsclose 1. That removes the toast, so React runs the cleanup one last time:stop timer 1. With Strict Mode off, the first two lines are missing. - You see #6, #4 and #2, from the top. In Strict Mode, React calls the reducer twice for each action, to check that it is pure (Part 15). Our reducer is not pure any more: it changes
nextId. So each action used up two IDs, and React kept the second answer. With Strict Mode off, the IDs were #3, #2 and #1. The ID made inadddoesn’t have this problem, becauseaddruns once for each click. Part 51 got the same IDs, 2, 4 and 6, from a counter in its reducer. - Only “Could not delete” shows. Adding it dropped “Saved”, and its timer was cleared: in our test, 0 timers were waiting. “Saved” doesn’t come back. A dropped toast is gone. The status region was emptied too, and the alert region held “Could not delete”.
- The toast leaves 5 seconds after Save, even with the focus on ×. Then the focus goes to
<body>, because the button that had it is gone. We checked this in jsdom.
Interview questions
Try to answer each one out loud before you open the answer.
How would you design a toast system in React?
A ToastProvider holds the list in a reducer, with added and removed actions. It gives the app a toast object, with success, error and info, through context. A useToast() hook reads it, and throws a clear error outside the provider. The provider draws the list with createPortal into document.body. Each toast is a ToastItem component with its own timer in an Effect.
A strong answer starts with questions: how long, how many, do errors stay, where on the screen. It names the accessibility parts without being asked, and keeps the API stable.
Why draw toasts in a portal? Does context still work there?
Toasts must show above everything. Inside the app, a parent with overflow: hidden and a transform can cut them off, even with position: fixed. A parent with a low z-index makes a stacking context: a group drawn as one layer. It can keep them under other parts of the page (Part 42). A portal draws them in document.body. They still belong to the provider in React’s tree. So context, state and events work as before (Part 42).
Where should the auto-close timer live, and why does it need a cleanup?
In the toast’s own component, in an Effect: const timer = setTimeout(...) and return () => clearTimeout(timer). Then the timer is tied to the toast’s life on the page. When the toast closes, is dropped, or the provider goes away, React runs the cleanup and the timer stops.
Without the cleanup, Strict Mode’s test run leaves a second timer in development. In production, a toast closed by hand still runs its timer later. A strong answer also lists onClose in the dependencies, and makes it stable with useCallback. Otherwise every render starts the timer again.
How do you pause the timer while the mouse is on the toast?
setTimeout can’t be paused, only cleared. So keep the time left in a ref. When the mouse comes onto the toast, the Effect’s cleanup clears the timer. It takes away the time that has passed. When the mouse leaves, the Effect starts a new timer for the time left. The Effect depends on a waits flag, so React does both at the right moment.
A strong answer pauses on focus too, with a separate flag, so a keyboard user on × doesn’t lose the toast. It also knows React’s onFocus and onBlur bubble.
How do you make toasts accessible?
Put two live regions on the page from the first render, empty: role="status" for most toasts and role="alert" for errors. Copy each toast’s text into the right one when it is added. Don’t move the focus. Give the close button a name, like aria-label="Close". Turn the animation off under prefers-reduced-motion: reduce.
A strong answer names the WCAG rules. Rule 4.1.3, “Status Messages”, asks that such messages reach screen readers “without receiving focus”. Rule 2.2.1 says a toast that leaves by itself must not be the only place where something is said. If it is, the user needs a way to turn the time limit off. A setting that keeps toasts open is one way. So errors and toasts with Undo usually stay until closed. It also knows a limit of the portal. While a modal <dialog> is open with showModal(), MDN says the rest of the page becomes “inert”. Then it can’t be focused or clicked. Our toasts are in <body>, outside the dialog. We tried it in the three browsers: focus() on a toast’s Close button left the focus in the dialog. A strong answer may also put the message in the Close button’s name, like “Close: Saved”, so several Close buttons can be told apart.
A new toast makes every component that reads the toast context render. Why, and how do you stop it?
Each toast changes the provider’s state, so the provider renders. If its context value is an object made in the render, it is new every time. So every reader renders. Make the toast object with useMemo and an empty list of dependencies. That is safe because dispatch never changes. In our test with Strict Mode off, Editor rendered 5 times without it, for 5 changes, and 0 times with it.
A strong answer keeps the list out of that context. If some component needs the list, it gets a second context, or a selector. It also knows that a stable toast in an Effect’s list is safe. But the Effect still runs twice on mount in Strict Mode. We tried useEffect(() => { toast.info('Welcome back') }, [toast]): two “Welcome back” toasts showed with Strict Mode on, and one with it off.
Why not use the index as the key for toasts?
New toasts are added at the start, so every index changes. React keeps state and Effects by key. With an index key, a new toast takes over the old toast’s timer and pause state. In our test, a toast added at 3 seconds left at 5 seconds. Use the toast’s ID.
Sources
- createPortal, react.dev: drawing children into another DOM node while they stay in the React tree.
- Scaling Up with Reducer and Context, react.dev: a provider with a reducer and custom hooks. useContext, react.dev: keeping a context value with
useMemo. - useReducer, react.dev: React calls the reducer twice in Strict Mode, and “The dispatch function has a stable identity”.
- useEffect, useRef, useMemo, useCallback and StrictMode, react.dev: cleanups, refs, keeping values and functions, and the extra setup and cleanup in development.
- Common components, react.dev: “in React the onFocus event bubbles”, and the same for
onBlur. - Understanding SC 2.2.1: Timing Adjustable, W3C: the rule’s options (turn off, adjust “at least ten times”, extend with “at least 20 seconds”), and the toast example that “disappears after 5 seconds”.
- Alert Pattern, W3C ARIA Authoring Practices Guide: alerts must not “affect keyboard focus”, and should not disappear automatically.
- MDN: alert role (“immediate attention”, the visually hidden container, not
display:none, and the same text twice), status role (“Do not give focus to the status”), ARIA live regions (“Establish the live region before updating its content”), prefers-reduced-motion and setTimeout (“The actual delay may be longer than set”). - Vitest’s
viAPI:vi.useFakeTimers(),vi.advanceTimersByTime(), and thatDate.nowis faked too. - The timings, timer counts, render counts and region contents above come from running React 19.3.0 for this post, in jsdom with fake timers, and in Chromium 151, Firefox 153 and WebKit 26.5 with real timers.
- This part follows the Toast / Notification System kata in react-katas. The kata starts the timer inside
addToast, as in “Try this first”, and keeps its ID counter outside the component. This part moves the timer into each toast’s Effect, so it can be paused and cleared.