How to Build a Reliable Countdown Timer in React: State Management and Best Practices

Implement a countdown timer in React using useEffect to manage the interval lifecycle, useRef to store the timer ID without triggering re-renders, and functional state updates to avoid stale closures.

A countdown timer in React represents a time-based side effect that requires careful management of intervals, state updates, and cleanup to prevent memory leaks. When building a timer in react applications, following the architectural patterns used in the facebook/react source code ensures your component remains declarative, performant, and free of stale closure bugs.

Core Architecture for a Countdown Timer in React

useEffect and Cleanup Patterns

The foundation of any reliable countdown timer in React starts with the useEffect hook and its cleanup mechanism. As demonstrated in fixtures/view-transition/src/components/Page.js, React components must clear intervals when unmounting or when dependencies change to prevent memory leaks.

useEffect(() => {
  const timer = setInterval(...);
  return () => clearInterval(timer);
}, []);

useRef for Mutable Timer State

Store the interval identifier in a useRef rather than state. This approach mirrors internal React timing utilities found in packages/react-reconciler/src/ReactProfilerTimer.js, where mutable timing data survives re-renders without triggering additional renders.

const intervalRef = useRef(null);
intervalRef.current = setInterval(...);

Functional State Updates

Always use the functional updater form setSeconds(prev => prev - 1) inside interval callbacks. This pattern prevents stale closures from capturing outdated state values, a technique consistently applied throughout the React codebase including fixtures/view-transition/src/components/Page.js.

Implementation Patterns for a Timer in React

Basic Countdown Starting on Mount

This implementation creates a simple countdown timer in react that initializes when the component mounts and cleans up automatically on unmount:

import { useState, useEffect, useRef } from 'react';

function Countdown({ initialSeconds }) {
  const [seconds, setSeconds] = useState(initialSeconds);
  const intervalRef = useRef(null);

  useEffect(() => {
    intervalRef.current = setInterval(() => {
      setSeconds(prev => {
        if (prev <= 0) {
          clearInterval(intervalRef.current);
          return 0;
        }
        return prev - 1;
      });
    }, 1000);

    return () => clearInterval(intervalRef.current);
  }, []);

  return <div>{seconds}s</div>;
}

Key points derived from the React source:

  • The interval is created inside useEffect to ensure it runs after mount.
  • The cleanup function prevents memory leaks when the component unmounts.
  • Functional updates ensure the decrement operation always uses the latest state.

Pausable and Resumable Timer

For a countdown timer in react that supports user controls, watch the paused prop in your dependency array:

import { useState, useEffect, useRef } from 'react';

function PausableCountdown({ initialSeconds, paused }) {
  const [seconds, setSeconds] = useState(initialSeconds);
  const intervalRef = useRef(null);

  useEffect(() => {
    if (paused) {
      clearInterval(intervalRef.current);
      intervalRef.current = null;
      return;
    }

    intervalRef.current = setInterval(() => {
      setSeconds(prev => {
        if (prev <= 0) {
          clearInterval(intervalRef.current);
          return 0;
        }
        return prev - 1;
      });
    }, 1000);

    return () => clearInterval(intervalRef.current);
  }, [paused]);

  return <div>{seconds}s</div>;
}

The dependency array contains paused because the effect must restart the interval when the pause state toggles, matching React's guidance on effect dependencies found in packages/react/src/ReactHooks.js.

Resettable Countdown with Dynamic Targets

When the target time changes, reset the countdown timer in react by including the target in the dependency array:

import { useState, useEffect, useRef } from 'react';

function ResettableCountdown({ targetDate }) {
  const calculateRemaining = () => 
    Math.max(0, Math.floor((targetDate - Date.now()) / 1000));
  
  const [seconds, setSeconds] = useState(calculateRemaining);
  const intervalRef = useRef(null);

  useEffect(() => {
    setSeconds(calculateRemaining());

    intervalRef.current = setInterval(() => {
      setSeconds(prev => {
        if (prev <= 0) {
          clearInterval(intervalRef.current);
          return 0;
        }
        return prev - 1;
      });
    }, 1000);

    return () => clearInterval(intervalRef.current);
  }, [targetDate]);

  return <div>{seconds}s left</div>;
}

This pattern mirrors how React's internal timers listen to configuration changes, as seen in packages/react-reconciler/src/ReactProfilerTimer.js, where timers are restarted when their underlying parameters change.

High-Precision Timer Using requestAnimationFrame

For sub-second precision in your countdown timer in react, replace setInterval with requestAnimationFrame:

import { useState, useEffect, useRef } from 'react';

function FineGrainedTimer({ durationMs }) {
  const [remaining, setRemaining] = useState(durationMs);
  const startRef = useRef(performance.now());
  const rafRef = useRef(null);

  const tick = now => {
    const elapsed = now - startRef.current;
    const newRemaining = Math.max(0, durationMs - elapsed);
    setRemaining(newRemaining);
    if (newRemaining > 0) {
      rafRef.current = requestAnimationFrame(tick);
    }
  };

  useEffect(() => {
    startRef.current = performance.now();
    rafRef.current = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(rafRef.current);
  }, [durationMs]);

  return <div>{(remaining / 1000).toFixed(2)}s</div>;
}

This approach is ideal for UI requiring smooth updates, such as progress bars or millisecond-accurate displays. The cleanup discipline remains identical to the setInterval patterns found in fixtures/view-transition/src/components/Page.js.

Key React Source Files for Reference

The following files in the facebook/react repository demonstrate the patterns you should follow when building a countdown timer in react:

File What you’ll find Why it matters for timers
[ReactHooks.js](https://github.com/facebook/react/blob/main/packages/react/src/ReactHooks.js) Source of useEffect, useState, useRef implementations. Understanding the contract of these hooks helps you use them correctly for timers.
[Page.js](https://github.com/facebook/react/blob/main/fixtures/view-transition/src/components/Page.js) Real‑world example of setInterval inside a useEffect with clean‑up. Directly demonstrates the pattern we recommend.
[ReactProfilerTimer.js](https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactProfilerTimer.js) Internal timer utilities used by the React Profiler. Shows how React stores mutable timing data and clears it, analogous to using useRef for interval IDs.
[useSyncExternalStoreWithSelector.js](https://github.com/facebook/react/blob/main/packages/use-sync-external-store/src/useSyncExternalStoreWithSelector.js) Uses useEffect to subscribe to external sources and clean up. Provides another reference for clean‑up of side‑effects.

Summary

  • Use useEffect with a cleanup function to manage the interval lifecycle and prevent memory leaks when the countdown timer in react unmounts.
  • Store the interval ID in useRef to maintain a mutable reference that persists across renders without causing re-renders.
  • Always use functional state updates (setState(prev => ...)) inside interval callbacks to avoid stale closure bugs.
  • Include relevant dependencies (like paused or targetDate) in the useEffect dependency array to control when the timer starts, stops, or resets.
  • For high-precision countdowns, consider requestAnimationFrame instead of setInterval, following the same cleanup patterns.

Frequently Asked Questions

Why does my countdown timer in react show stale values or skip numbers?

This happens when your interval callback captures a stale closure of the state value. Always use the functional updater form setSeconds(prev => prev - 1) to ensure you read the latest state. This pattern mirrors how React's internal schedulers avoid stale reads by referencing mutable refs or latest closures, as implemented in packages/react/src/ReactHooks.js.

Should I use setInterval or setTimeout for a countdown timer in react?

Use setInterval for recurring ticks every second, but only if you implement proper cleanup in useEffect. While setTimeout can work for recursive delays, setInterval is more efficient for consistent 1-second updates. Always clear the timer on unmount to prevent memory leaks, following the pattern demonstrated in fixtures/view-transition/src/components/Page.js.

How do I prevent memory leaks when my timer component unmounts?

Return a cleanup function from your useEffect that calls clearInterval(intervalRef.current) or cancelAnimationFrame(rafRef.current). Store the timer ID in a useRef rather than state to avoid re-renders when the ID changes. This follows the same cleanup discipline React uses in packages/react-reconciler/src/ReactProfilerTimer.js and other internal timing utilities.

Can I use Date.now() directly inside the interval callback?

Avoid calling Date.now() directly inside the interval to calculate remaining time, as this can drift from your component's state and cause synchronization bugs. Instead, calculate the end time once (or use a targetDate prop) and decrement a seconds counter, or use a ref to track the start time while updating state only for display purposes. This keeps your UI state as the single source of truth and prevents inconsistencies between the system clock and React's render cycle.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →