Build a countdown timer
mediumState & Data+200 XP
Build a 5-second countdown with Start/Pause and Reset controls.
Pausing has to actually stop the countdown, and resuming has to continue from where it left off — not restart, and not keep ticking in the background while "paused".
Examples
// Initial: time-left = 5, button reads "Start"
// Click "Start"
// → each second: 4, 3, 2, 1, 0
// → button reads "Pause"
// Click "Pause" at 3
// → count holds at 3 indefinitely
// Click "Pause" (now "Start") again
// → count resumes 2, 1, 0 — not restarts at 3.Pause/resume preserves the remaining seconds; reset snaps to 5 and stops.
useEffect(() => {
if (!running) return;
if (secondsLeft === 0) {
setRunning(false);
return;
}
const id = setInterval(() => {
setSecondsLeft((s) => Math.max(0, s - 1));
}, 1000);
return () => clearInterval(id);
}, [running, secondsLeft]);Gate the interval on `running`; the cleanup is what makes Pause actually stop ticking.
What it has to do
- A `data-testid="time-left"` element shows the remaining whole seconds, starting at 5.
- A `data-testid="start-pause"` button reads "Start" while stopped and "Pause" while running, and toggles between the two states.
- While running, the count decreases by 1 every 1000ms.
- While paused, the count does not change at all, however long you wait.
- A `data-testid="reset"` button returns the count to 5 and stops the timer.
Notes
- The classic bug: a stale interval kept ticking even while paused. Gate `setInterval` on the running flag, and let React's effect cleanup clear it on toggle.
- For wall-clock accuracy under tab throttling, store a target end time and derive `secondsLeft = Math.max(0, (end - Date.now()) / 1000)` on each tick — a plain decrement drifts if the tab is backgrounded.
- Stop the timer automatically when it hits 0 (set `running = false`) — otherwise the effect dependency churn creates and tears down the interval every re-render.
Stuck? The react course covers the ideas this problem is built on.
import { useEffect, useState } from "react"; const START_SECONDS = 5; export default function App() { const [secondsLeft, setSecondsLeft] = useState(START_SECONDS); const [running, setRunning] = useState(false); useEffect(() => { // TODO: this interval should only tick while `running` is true — right // now it counts down regardless of whether the timer is "paused". const id = setInterval(() => { setSecondsLeft((s) => Math.max(0, s - 1)); }, 1000); return () => clearInterval(id); }, []); return ( <div style={{ fontFamily: "sans-serif", padding: 16 }}> <p style={{ fontSize: 32 }}> <span data-testid="time-left">{secondsLeft}</span>s </p> <button data-testid="start-pause" onClick={() => setRunning((r) => !r)}> {running ? "Pause" : "Start"} </button> <button data-testid="reset" onClick={() => { setSecondsLeft(START_SECONDS); setRunning(false); }} style={{ marginLeft: 8 }} > Reset </button> </div> ); }
- 1.A `data-testid="time-left"` element shows the remaining whole seconds, starting at 5.
- 2.A `data-testid="start-pause"` button reads "Start" while stopped and "Pause" while running, and toggles between the two states.
- 3.While running, the count decreases by 1 every 1000ms.
- 4.While paused, the count does not change at all, however long you wait.
- 5.A `data-testid="reset"` button returns the count to 5 and stops the timer.
5 automated assertions run against your rendered component. The exact assertion names are hidden so you build to the behaviour, not to the test.
5 tests · +200 XP
Discussion
Sign in to postSign in to join the conversation.Sign in
No comments yet. Be the first to say something.