Build a Countdown Timer
Learn how to build a countdown timer with start, pause, and reset controls using React hooks, refs, and setInterval. A classic frontend interview question that tests your understanding of side effects and cleanup.
Table of Contents
Problem Statement
Build a fully functional countdown timer with the following features:
- Display a countdown starting from a configurable time (MM:SS)
- Start button begins the countdown, decrementing every second
- Pause button freezes the countdown at the current time
- Reset button stops the timer and restores the initial time
- Timer stops automatically when it reaches 00:00
- Visual indicator when time is up (e.g., color change)
- Allow the user to adjust minutes and seconds before starting
Why this question?
The countdown timer is a popular frontend interview question because it tests your understanding of setInterval, useRef vs useState, cleanup with useEffect, and handling stale closures — all core React concepts that trip up many candidates.
Concepts Overview
This component is entirely client-side. It relies on three key React primitives working together:
useState
Tracks the remaining time, running state, and user-configured minutes/seconds. Triggers re-renders when the timer ticks.
useRef
Stores the setInterval ID without causing re-renders. Essential for clearing the interval on pause, reset, or unmount.
useEffect
Handles cleanup — clears the interval when the component unmounts to prevent memory leaks and stale callbacks.
useState → triggers re-render on every update useRef → persists value across renders WITHOUT re-rendering Interval ID doesn't need to be displayed in the UI, so useRef is the right choice. Using useState would cause unnecessary re-renders every time you start/stop the timer.
Component Architecture
This is a single-file client component. No API routes, no server components — just pure React state and browser APIs.
├── app/ │ └── (solution-apps)/ │ └── questions/ │ └── countdown-timer/ │ └── page.tsx # Client Component — all logic & UI │ │ Key pieces inside the file: │ ├── INITIAL_MINUTES / INITIAL_SECONDS — default config │ ├── formatTime() — helper to format MM:SS │ └── CountdownTimerPage() — main component
Interview tip
Extracting formatTime() as a pure function outside the component shows you understand separation of concerns. It's easy to test, doesn't depend on React, and won't be recreated on every render.
Core Implementation
Step 1: Constants & Helper
Define the default timer values and a pure formatting function outside the component.
const INITIAL_MINUTES = 5; const INITIAL_SECONDS = 0; function formatTime(totalSeconds: number): string { const mins = Math.floor(totalSeconds / 60); const secs = totalSeconds % 60; return `${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}`; }
Step 2: State & Refs
Set up all the state variables and the interval ref. Notice how the interval ID lives in a ref, not state.
const [timeLeft, setTimeLeft] = useState( INITIAL_MINUTES * 60 + INITIAL_SECONDS ); const [isRunning, setIsRunning] = useState(false); const [minutes, setMinutes] = useState(INITIAL_MINUTES); const [seconds, setSeconds] = useState(INITIAL_SECONDS); const intervalRef = useRef<NodeJS.Timeout | null>(null);
Step 3: Start, Pause & Reset
The three core actions. Pay close attention to how setTimeLeft uses a functional update to avoid stale closures.
function start() { if (timeLeft <= 0) return; // Guard: don't start at 0 setIsRunning(true); intervalRef.current = setInterval(() => { setTimeLeft((prev) => { if (prev <= 1) { clearInterval(intervalRef.current!); setIsRunning(false); return 0; } return prev - 1; }); }, 1000); } function pause() { setIsRunning(false); if (intervalRef.current) clearInterval(intervalRef.current); } function reset() { setIsRunning(false); if (intervalRef.current) clearInterval(intervalRef.current); setTimeLeft(minutes * 60 + seconds); // Restore user-configured time }
Stale closure trap
If you wrote setTimeLeft(timeLeft - 1) inside the interval, it would always use the value of timeLeft from when the interval was created — never updating. The functional form setTimeLeft(prev => prev - 1) always reads the latest value. This is the #1 mistake candidates make.
Step 4: Cleanup on Unmount
Always clear the interval when the component unmounts. Without this, the interval keeps running in the background — a memory leak.
useEffect(() => { return () => { intervalRef.current && clearInterval(intervalRef.current); }; }, []);
Step 5: Time Adjuster Inputs
Let users configure the starting time. The inputs are disabled while the timer is running to prevent mid-countdown changes.
<input type="number" min={0} max={99} value={minutes} disabled={isRunning} onChange={(e) => { const m = Math.max(0, Math.min(99, Number(e.target.value) || 0)); setMinutes(m); setTimeLeft(m * 60 + seconds); // Sync timeLeft immediately }} /> <input type="number" min={0} max={59} value={seconds} disabled={isRunning} onChange={(e) => { const s = Math.max(0, Math.min(59, Number(e.target.value) || 0)); setSeconds(s); setTimeLeft(minutes * 60 + s); }} />
Data Flow
Understanding how data flows through the timer is key. Every action follows a predictable pattern:
Initial Render
Component mounts with timeLeft set to INITIAL_MINUTES × 60 + INITIAL_SECONDS. The formatTime helper converts total seconds to MM:SS for display. No interval is running yet.
User Clicks Start
start() is called. It sets isRunning to true and creates a setInterval that fires every 1000ms. Each tick calls setTimeLeft(prev => prev - 1), which triggers a re-render with the new time. The interval ID is stored in intervalRef.current.
User Clicks Pause
pause() is called. It sets isRunning to false and calls clearInterval using the ref. The timeLeft value stays where it is — the display freezes. Clicking Start again creates a new interval from the current timeLeft.
Timer Reaches Zero
Inside the interval callback, when prev <= 1, the interval clears itself, sets isRunning to false, and returns 0. The UI shows 00:00 with a red color and a 'Time's up!' message.
User Clicks Reset
reset() clears the interval, sets isRunning to false, and restores timeLeft to minutes × 60 + seconds (the user-configured values). The timer is ready to start again.
Key insight
The interval callback checks prev <= 1 (not prev === 0) because the decrement happens in the same callback. When prev is 1, the next value would be 0 — so we stop there and return 0 directly.
State Management
The timer uses plain useState and useRef — no external libraries needed. Here's the breakdown:
| Variable | Type | Purpose |
|---|---|---|
| timeLeft | number | Remaining time in total seconds |
| isRunning | boolean | Whether the timer is currently counting down |
| minutes | number | User-configured starting minutes |
| seconds | number | User-configured starting seconds |
| intervalRef | Ref<NodeJS.Timeout | null> | Stores interval ID — not state, no re-renders |
Why Not a Single State Object?
// ❌ Tempting but problematic inside setInterval: const [state, setState] = useState({ timeLeft: 300, isRunning: false, minutes: 5, seconds: 0, }); // You'd need to spread the entire object on every tick, // and risk stale closures on the other fields. // ✅ Better: separate concerns const [timeLeft, setTimeLeft] = useState(300); const [isRunning, setIsRunning] = useState(false); // timeLeft updates independently every second. // isRunning only changes on start/pause/reset.
Interview tip
If asked "when would you use useReducer here?" — a good answer is when you add features like lap times, multiple timers, or undo. For a single countdown, useState keeps things simple and readable.
Performance Considerations
Performance questions are common follow-ups for timer problems. Here are the key points and potential improvements:
useRef for Interval ID
The interval ID is stored in a ref, not state. This avoids a re-render every time the interval is created or cleared — only the tick itself triggers a render.
Functional State Updates
setTimeLeft(prev => prev - 1) ensures the callback always reads the latest value, avoiding stale closure bugs that plague setInterval patterns.
useEffect Cleanup
The cleanup function in useEffect clears the interval on unmount, preventing memory leaks and ghost intervals running in the background.
Disabled Inputs While Running
Time adjuster inputs are disabled during countdown, preventing conflicting state updates between user input and the running interval.
requestAnimationFrame
For sub-second precision (e.g., showing milliseconds), replace setInterval with requestAnimationFrame and track elapsed time with Date.now().
Web Workers
Move the timer to a Web Worker so it keeps running accurately even when the tab is in the background. Browser tabs throttle setInterval to ~1s when inactive.
React.memo for Sub-components
If the timer display and controls were separate components, wrapping them in React.memo would prevent the controls from re-rendering on every tick.
// More accurate than setInterval for high-precision timers function startPrecise() { const endTime = Date.now() + timeLeft * 1000; function tick() { const remaining = Math.max(0, endTime - Date.now()); setTimeLeft(Math.ceil(remaining / 1000)); if (remaining > 0) { rafRef.current = requestAnimationFrame(tick); } else { setIsRunning(false); } } setIsRunning(true); rafRef.current = requestAnimationFrame(tick); }
Common Interview Follow-up Questions
After building the countdown timer, interviewers often dig deeper. Here are the most common questions with strong answers:
Q:Why useRef instead of useState for the interval ID?
A: useState triggers a re-render on every update. The interval ID is an internal implementation detail — it's never displayed in the UI. useRef persists the value across renders without causing re-renders, making it the right tool for storing mutable values that don't affect the visual output.
Q:What happens if the user switches tabs?
A: Browsers throttle setInterval in background tabs to roughly once per second (or even less). The timer will appear to 'jump' when the user returns. To fix this, calculate the target end time with Date.now() and compute remaining time on each tick, rather than decrementing by 1.
Q:How would you add a lap/split time feature?
A: Maintain an array of lap times in state: useState<number[]>([]). When the user clicks 'Lap', push the current timeLeft to the array. Display laps in a list below the timer. This is a good case for useReducer since you're managing related state transitions.
Q:How would you make the timer persist across page refreshes?
A: Store the target end time (Date.now() + remainingMs) in localStorage when the timer starts. On mount, check localStorage — if a target time exists and is in the future, resume the countdown from the remaining difference. Clear localStorage on reset or completion.
Q:How would you test this component?
A: Use jest.useFakeTimers() to control time. Render the component with React Testing Library, click Start, advance timers with jest.advanceTimersByTime(3000), and assert the display shows the correct time. Test edge cases: starting at 0, pausing and resuming, unmounting while running.
Q:What's the difference between setInterval and setTimeout for this?
A: setInterval fires repeatedly at a fixed interval. setTimeout fires once. You could use recursive setTimeout (schedule the next tick at the end of each callback) for more predictable timing since setInterval can drift. Both work, but setInterval is simpler for this use case.
Q:How would you add sound or notification when time is up?
A: Use the Web Audio API or an Audio element to play a sound at zero. For notifications, use the Notification API (with permission). You could also use the Vibration API on mobile. Trigger these in the same block where you detect prev <= 1.
Q:How would you handle multiple concurrent timers?
A: Extract the timer logic into a custom hook (useCountdown) that returns { timeLeft, isRunning, start, pause, reset }. Each timer instance calls the hook independently. The hook encapsulates its own state and ref, so multiple timers don't interfere with each other.
Ready to build it yourself?
Put your knowledge to the test. Build the countdown timer from scratch with our interactive challenge.
Built for developers, by developers. Happy coding! 🚀