ReactHooksuseRefsetIntervalClient Component

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.

25 min read8 sections
01

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.

02

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.

Why useRef for the interval?text
useStatetriggers re-render on every update
useRefpersists 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.
03

Component Architecture

This is a single-file client component. No API routes, no server components — just pure React state and browser APIs.

Project Structuretext
├── app/
│   └── (solution-apps)/
│       └── questions/
│           └── countdown-timer/
│               └── page.tsx    # Client Componentall logic & UI

Key pieces inside the file:
│   ├── INITIAL_MINUTES / INITIAL_SECONDSdefault 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.

04

Core Implementation

Step 1: Constants & Helper

Define the default timer values and a pure formatting function outside the component.

countdown-timer/page.tsx — setuptypescript
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.

countdown-timer/page.tsx — statetypescript
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.

countdown-timer/page.tsx — actionstypescript
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.

countdown-timer/page.tsx — cleanuptypescript
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.

countdown-timer/page.tsx — adjustertypescript
<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);
  }}
/>
05

Data Flow

Understanding how data flows through the timer is key. Every action follows a predictable pattern:

1

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.

2

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.

3

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.

4

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.

5

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.

06

State Management

The timer uses plain useState and useRef — no external libraries needed. Here's the breakdown:

VariableTypePurpose
timeLeftnumberRemaining time in total seconds
isRunningbooleanWhether the timer is currently counting down
minutesnumberUser-configured starting minutes
secondsnumberUser-configured starting seconds
intervalRefRef<NodeJS.Timeout | null>Stores interval ID — not state, no re-renders

Why Not a Single State Object?

Separate vs. combined statetypescript
// ❌ 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.

07

Performance Considerations

Performance questions are common follow-ups for timer problems. Here are the key points and potential improvements:

✓ Done

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.

✓ Done

Functional State Updates

setTimeLeft(prev => prev - 1) ensures the callback always reads the latest value, avoiding stale closure bugs that plague setInterval patterns.

✓ Done

useEffect Cleanup

The cleanup function in useEffect clears the interval on unmount, preventing memory leaks and ghost intervals running in the background.

✓ Done

Disabled Inputs While Running

Time adjuster inputs are disabled during countdown, preventing conflicting state updates between user input and the running interval.

→ Could add

requestAnimationFrame

For sub-second precision (e.g., showing milliseconds), replace setInterval with requestAnimationFrame and track elapsed time with Date.now().

→ Could add

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.

→ Could add

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.

Example: requestAnimationFrame approachtypescript
// 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);
}
08

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! 🚀