How to Build a Reliable React Timer with Hooks for Countdown Functionality
Use useEffect with a cleanup function that calls clearInterval to manage intervals, store the timer ID in a useRef to prevent duplicate instances, and initialize timers only after component mount using an empty dependency array.
Managing timers in React function components requires careful coordination of side effects to prevent memory leaks, stale closures, and duplicate intervals. The facebook/react repository demonstrates the canonical implementation pattern in fixtures/nesting/src/shared/useTime.js, showing how to leverage useEffect for lifecycle management and useRef for stable timer references. By following these patterns, you can build countdown functionality that starts and stops reliably without leaking memory or causing unexpected re-renders.
The Canonical Timer Pattern in React
The React team maintains a reference implementation of a timer hook in the repository's fixture code. In fixtures/nesting/src/shared/useTime.js, the implementation demonstrates the essential pattern for reliable timer management: initializing the interval inside useEffect, storing the interval ID, and returning a cleanup function.
This pattern ensures that the timer starts exactly once when the component mounts and cleans up automatically when the component unmounts, preventing the common pitfall of intervals continuing to run after the component is gone.
Building a Basic Timer Hook
The useTime.js fixture provides a minimal, reusable hook that updates every second. This implementation serves as the foundation for any timer-based functionality, including countdowns.
import {useState, useEffect} from 'react';
export default function useTimer() {
const [value, setValue] = useState(() => new Date());
useEffect(() => {
const id = setInterval(() => {
setValue(new Date());
}, 1000);
return () => clearInterval(id); // ← cleanup on unmount
}, []); // ← empty deps → run once
return value.toLocaleTimeString();
}
Key implementation details from the React source:
- Start only once: The empty dependency array (
[]) ensures the effect runs only after the initial render, preventing duplicate intervals. - Clean up: The returned function calls
clearInterval(id), guaranteeing the timer stops when the component unmounts or when the effect re-runs. - State updates:
setValuetriggers a re-render with the new time value on each interval tick. - Reference stability: The interval ID is stored in a local variable (
id) scoped to the effect, avoiding recreation on every render.
Implementing a Countdown Timer with Start/Stop Controls
To create a countdown that can be started, paused, and reset, extend the basic pattern by adding state for the remaining time and control flags. This implementation uses useRef to store the interval ID persistently across renders without triggering re-renders.
import { useState, useEffect, useRef, useCallback } from 'react';
/**
* useCountdown
* @param {number} startSeconds – initial countdown value in seconds
* @param {boolean} autoStart – start the timer immediately when mounted
* @returns {{
* time: number,
* start: () => void,
* pause: () => void,
* reset: (seconds?: number) => void,
* isRunning: boolean
* }}
*/
export function useCountdown(startSeconds: number, autoStart = true) {
const [time, setTime] = useState(startSeconds);
const [isRunning, setRunning] = useState(autoStart);
const intervalRef = useRef<number | null>(null);
const clear = useCallback(() => {
if (intervalRef.current !== null) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
}, []);
// Start / pause logic
const start = useCallback(() => {
if (intervalRef.current !== null) return; // already running
setRunning(true);
intervalRef.current = window.setInterval(() => {
setTime(prev => {
if (prev <= 1) {
clear(); // stop at 0
setRunning(false);
return 0;
}
return prev - 1;
});
}, 1000);
}, [clear]);
const pause = useCallback(() => {
clear();
setRunning(false);
}, [clear]);
const reset = useCallback((seconds?: number) => {
clear();
setTime(seconds ?? startSeconds);
if (autoStart) start();
}, [clear, start, startSeconds, autoStart]);
// Cleanup on unmount (mirrors the pattern in useTimer)
useEffect(() => {
if (autoStart) start();
return clear; // <-- guarantees no stray intervals
}, [autoStart, start, clear]);
return { time, start, pause, reset, isRunning };
}
Usage in a component:
import React from 'react';
import { useCountdown } from './useCountdown';
export default function CountdownDisplay() {
const { time, start, pause, reset, isRunning } = useCountdown(30, false);
return (
<div>
<h1>{time}s</h1>
<button onClick={isRunning ? pause : start}>
{isRunning ? 'Pause' : 'Start'}
</button>
<button onClick={() => reset(30)}>Reset to 30s</button>
</div>
);
}
Key Source Files in the React Repository
The following files from the facebook/react repository demonstrate the patterns and constraints governing hook-based timers:
fixtures/nesting/src/shared/useTime.js– Contains the canonicaluseTimerimplementation showing interval initialization and cleanup.packages/react/src/ReactHooks.js– Internal hook registration logic that explains why stable references matter for effects.packages/react-devtools-timeline/src/Timeline.js– Practical example of interval usage in the DevTools timeline feature.packages/eslint-plugin-react-hooks/__tests__/ESLintRulesOfHooks-test.js– Test suite enforcing the Rules of Hooks, ensuring your timer logic respects dependency arrays.
Summary
- Use
useEffectto encapsulate timer side effects, ensuring they start after render and clean up on unmount. - Return a cleanup function from
useEffectthat callsclearIntervalto prevent memory leaks and duplicate timers. - Store interval IDs in
useRefor local effect variables to maintain stable references across renders. - Guard against duplicates by checking if an interval is already running before starting a new one.
- Follow the pattern demonstrated in
fixtures/nesting/src/shared/useTime.jsfrom thefacebook/reactrepository for canonical implementation details.
Frequently Asked Questions
Why does my React timer run multiple times or speed up unexpectedly?
This happens when you start a new interval on every render without cleaning up the previous one. Always store the interval ID in a useRef or return a cleanup function from useEffect that calls clearInterval. The useTime.js fixture in the React repository demonstrates the correct pattern of using an empty dependency array to ensure the effect runs only once.
Should I use setInterval or setTimeout for a countdown timer?
Both work, but setInterval is more convenient for recurring ticks like a countdown. However, setTimeout can be safer if you need dynamic interval changes because it doesn't risk overlapping calls if the component re-renders slowly. Regardless of which you choose, always clear the timer in the useEffect cleanup function to match the pattern shown in fixtures/nesting/src/shared/useTime.js.
How do I pause and resume a timer without resetting the value?
Store the interval ID in a useRef so it persists across renders without triggering re-renders. Create start and pause callbacks that check intervalRef.current to prevent duplicate intervals. When pausing, call clearInterval(intervalRef.current) and set the ref to null. When starting, only create a new interval if the ref is null. This approach mirrors the control logic in the useCountdown implementation derived from React's core patterns.
Why is cleanup important when using timers in React?
React components mount and unmount dynamically, especially in single-page applications with routing. If you don't clear intervals when a component unmounts, the JavaScript engine continues executing the callback, trying to update state on a component that no longer exists. This causes memory leaks and "Can't perform a React state update on an unmounted component" warnings. The useEffect cleanup function, as demonstrated in packages/react/src/ReactHooks.js and the useTime.js fixture, ensures resources are released when the component lifecycle ends.
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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →