A toast notification is a short status message that appears without interrupting the current task and dismisses itself after a delay. Build one in React that confirms a save, supports an immediate manual close, and restarts its countdown when it is shown again.
Implement the App component in App.tsx. Keep the toast's visibility in React state and keep its timeout id in a ref so every handler can cancel the same pending callback.
× button: the toast disappears immediately and the pending auto-dismiss is cancelled.useRef; it is not rendered data.role="status" and an aria-live="polite" region; label the icon-only close button.You'll hold one visible boolean in state and keep the pending timeout's id in a useRef so you can cancel it from either the close button or the next click.
Showing the toast is easy — flip a boolean. The interesting part is un-showing it two different ways: automatically after 3 seconds, and manually the instant someone clicks ×. Both paths race over the same pending timer, so you need a handle on that timer to cancel it. Lose the handle and you get ghost timeouts firing after the toast is already gone.
setTimeout returns an id — a claim ticket for a scheduled callback. Whoever holds the id can clearTimeout it. The whole question is where to keep that id so both the close button and a rapid re-click can reach it. In React the answer is a useRef: it survives re-renders, and mutating it does not cause one.
The obvious version just schedules a hide and calls it done:
export default function App() {
const [visible, setVisible] = useState(false);
function show() {
setVisible(true);
setTimeout(() => setVisible(false), 3000); // id thrown away
}
return (
<main className="container">
<button className="trigger" onClick={show}>Show notification</button>
{visible && <div className="toast"><span>Saved successfully</span></div>}
</main>
);
}
It works until you interact with it. There's no way to close early — the × handler has no id to clear. And clicking Show twice schedules two timeouts: the first still fires 3 seconds in and hides the toast out from under the second. The id came back from setTimeout and you dropped it on the floor.
import { useEffect, useRef, useState } from 'react';
import './styles.css';
export default function App() {
const [visible, setVisible] = useState(false);
const timer = useRef<number | undefined>(undefined);
function show() {
clearTimeout(timer.current); // cancel any in-flight timer first
setVisible(true);
timer.current = window.setTimeout(() => setVisible(false), 3000);
}
function dismiss() {
clearTimeout(timer.current);
setVisible(false);
}
useEffect(() => () => clearTimeout(timer.current), []); // clear on unmount
return (
<main className="container">
<h1>Toast</h1>
<button type="button" className="trigger" onClick={show}>
Show notification
</button>
{visible && (
<div className="toast" role="status" aria-live="polite">
<span>Saved successfully</span>
<button type="button" className="close" aria-label="Dismiss" onClick={dismiss}>
×
</button>
</div>
)}
</main>
);
}
visible drives the render; timer.current holds the live timeout id. Every entry point that hides the toast — the 3s callback, dismiss, and the top of show — shares that handle, so there is never more than one pending timer. The useEffect cleanup runs on unmount and cancels a timer that would otherwise fire after the component is gone.
The order at the top of show matters. Clearing the old id before assigning the new one turns repeated clicks into a restart instead of two callbacks racing to hide the same message.
clearTimeout(undefined) is a harmless no-op, visible becomes true, the toast renders, and timer.current now holds a fresh id scheduled for 3s out.show clears the first id (so it will not fire), sets visible true again, and schedules a new 3s timer. Still one toast, countdown restarted.×: dismiss clears the pending id and sets visible false — the toast is gone now and the timer never fires.setTimeout id — without it you can't cancel; the close button is dead and rapid clicks stack timers. Capture it in a ref.useState — it isn't rendered, and setting state to hold it triggers pointless re-renders. A useRef is the right home for a mutable non-visual handle.setVisible on a gone component. The useEffect cleanup clears it.{ id, message } and giving each its own timer (a Map of ids).mouseenter and restart it on mouseleave so a reader isn't rushed.This version stores visibility and a countdown version together. Every show action increments the version, so the effect cleanup cancels the previous timeout even while the toast is already visible.
import { useEffect, useReducer } from 'react';
import './styles.css';
type ToastState = { visible: boolean; version: number };
type ToastAction = { type: 'show' } | { type: 'dismiss' };
function reduceToast(state: ToastState, action: ToastAction): ToastState {
if (action.type === 'show') {
return { visible: true, version: state.version + 1 };
}
return { ...state, visible: false };
}
export default function App() {
const [toast, dispatch] = useReducer(reduceToast, {
visible: false,
version: 0,
});
useEffect(() => {
if (!toast.visible) return;
const timer = window.setTimeout(() => dispatch({ type: 'dismiss' }), 3000);
return () => clearTimeout(timer);
}, [toast.visible, toast.version]);
return (
<main className="container">
<h1>Toast</h1>
<button type="button" className="trigger" onClick={() => dispatch({ type: 'show' })}>
Show notification
</button>
{toast.visible && (
<div className="toast" role="status" aria-live="polite">
<span>Saved successfully</span>
<button
type="button"
className="close"
aria-label="Dismiss"
onClick={() => dispatch({ type: 'dismiss' })}
>
×
</button>
</div>
)}
</main>
);
}Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
A toast notification is a short status message that appears without interrupting the current task and dismisses itself after a delay. Build one in React that confirms a save, supports an immediate manual close, and restarts its countdown when it is shown again.
Implement the App component in App.tsx. Keep the toast's visibility in React state and keep its timeout id in a ref so every handler can cancel the same pending callback.
× button: the toast disappears immediately and the pending auto-dismiss is cancelled.useRef; it is not rendered data.role="status" and an aria-live="polite" region; label the icon-only close button.You'll hold one visible boolean in state and keep the pending timeout's id in a useRef so you can cancel it from either the close button or the next click.
Showing the toast is easy — flip a boolean. The interesting part is un-showing it two different ways: automatically after 3 seconds, and manually the instant someone clicks ×. Both paths race over the same pending timer, so you need a handle on that timer to cancel it. Lose the handle and you get ghost timeouts firing after the toast is already gone.
setTimeout returns an id — a claim ticket for a scheduled callback. Whoever holds the id can clearTimeout it. The whole question is where to keep that id so both the close button and a rapid re-click can reach it. In React the answer is a useRef: it survives re-renders, and mutating it does not cause one.
The obvious version just schedules a hide and calls it done:
export default function App() {
const [visible, setVisible] = useState(false);
function show() {
setVisible(true);
setTimeout(() => setVisible(false), 3000); // id thrown away
}
return (
<main className="container">
<button className="trigger" onClick={show}>Show notification</button>
{visible && <div className="toast"><span>Saved successfully</span></div>}
</main>
);
}
It works until you interact with it. There's no way to close early — the × handler has no id to clear. And clicking Show twice schedules two timeouts: the first still fires 3 seconds in and hides the toast out from under the second. The id came back from setTimeout and you dropped it on the floor.
import { useEffect, useRef, useState } from 'react';
import './styles.css';
export default function App() {
const [visible, setVisible] = useState(false);
const timer = useRef<number | undefined>(undefined);
function show() {
clearTimeout(timer.current); // cancel any in-flight timer first
setVisible(true);
timer.current = window.setTimeout(() => setVisible(false), 3000);
}
function dismiss() {
clearTimeout(timer.current);
setVisible(false);
}
useEffect(() => () => clearTimeout(timer.current), []); // clear on unmount
return (
<main className="container">
<h1>Toast</h1>
<button type="button" className="trigger" onClick={show}>
Show notification
</button>
{visible && (
<div className="toast" role="status" aria-live="polite">
<span>Saved successfully</span>
<button type="button" className="close" aria-label="Dismiss" onClick={dismiss}>
×
</button>
</div>
)}
</main>
);
}
visible drives the render; timer.current holds the live timeout id. Every entry point that hides the toast — the 3s callback, dismiss, and the top of show — shares that handle, so there is never more than one pending timer. The useEffect cleanup runs on unmount and cancels a timer that would otherwise fire after the component is gone.
The order at the top of show matters. Clearing the old id before assigning the new one turns repeated clicks into a restart instead of two callbacks racing to hide the same message.
clearTimeout(undefined) is a harmless no-op, visible becomes true, the toast renders, and timer.current now holds a fresh id scheduled for 3s out.show clears the first id (so it will not fire), sets visible true again, and schedules a new 3s timer. Still one toast, countdown restarted.×: dismiss clears the pending id and sets visible false — the toast is gone now and the timer never fires.setTimeout id — without it you can't cancel; the close button is dead and rapid clicks stack timers. Capture it in a ref.useState — it isn't rendered, and setting state to hold it triggers pointless re-renders. A useRef is the right home for a mutable non-visual handle.setVisible on a gone component. The useEffect cleanup clears it.{ id, message } and giving each its own timer (a Map of ids).mouseenter and restart it on mouseleave so a reader isn't rushed.This version stores visibility and a countdown version together. Every show action increments the version, so the effect cleanup cancels the previous timeout even while the toast is already visible.
import { useEffect, useReducer } from 'react';
import './styles.css';
type ToastState = { visible: boolean; version: number };
type ToastAction = { type: 'show' } | { type: 'dismiss' };
function reduceToast(state: ToastState, action: ToastAction): ToastState {
if (action.type === 'show') {
return { visible: true, version: state.version + 1 };
}
return { ...state, visible: false };
}
export default function App() {
const [toast, dispatch] = useReducer(reduceToast, {
visible: false,
version: 0,
});
useEffect(() => {
if (!toast.visible) return;
const timer = window.setTimeout(() => dispatch({ type: 'dismiss' }), 3000);
return () => clearTimeout(timer);
}, [toast.visible, toast.version]);
return (
<main className="container">
<h1>Toast</h1>
<button type="button" className="trigger" onClick={() => dispatch({ type: 'show' })}>
Show notification
</button>
{toast.visible && (
<div className="toast" role="status" aria-live="polite">
<span>Saved successfully</span>
<button
type="button"
className="close"
aria-label="Dismiss"
onClick={() => dispatch({ type: 'dismiss' })}
>
×
</button>
</div>
)}
</main>
);
}Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.