useInterval & useTimeout: Escaping React's Closure Trap

Chris is building a simple 'Countdown Timer'.
Itβs a feature where the number decreases by 1 every second. Having moved past his beginner phase in React, Chris confidently combined useEffect and setInterval.
// β Chris's Frozen Timer
function Timer() {
const [count, setCount] = useState(10);
useEffect(() => {
const id = setInterval(() => {
console.log(count); // Debugging
setCount(count - 1);
}, 1000);
return () => clearInterval(id);
}, []); // Run only once on mount
return <h1>Time Left: {count}</h1>;
}Contrary to expectations, the number on the screen changed to 9 once and then froze forever. Checking the console, he saw 10, 10, 10... printing infinitely.
This is the infamous "Stale Closure" problem that every React developer faces at least once.
Today, we will understand where the characteristics of JavaScript closures collide with the React lifecycle, and build a useInterval hook that solves this elegantly using useRef.
1. The Cause: A Space Where Time Stopped
Why doesn't count decrease?
It is because the dependency array (deps) of useEffect is empty [].
A Wrong Solution: Adding to the Dependency Array
"Then can't we just add [count] to the dependencies?"
useEffect(() => {
const id = setInterval(...);
return () => clearInterval(id);
}, [count]); // Re-run whenever count changesIt works. However, this means clearing and re-registering the timer every second.
Not only does the timer's precision drop, but if the interval duration is short, performance issues can arise. We want to "keep the timer running, but let the internal logic know the latest state."
2. The Solution: Capturing the Latest State with useRef
The pattern proposed by Dan Abramov from the React team to solve this is useInterval.
The core idea is to "Separate what changes (Callback) from what doesn't change (Interval)."
Implementing useInterval
// useInterval.ts
import { useEffect, useRef } from 'react';
export function useInterval(callback: () => void, delay: number | null) {
// 1. Create a ref to store the latest callback
const savedCallback = useRef(callback);
// 2. Update the ref with the latest callback whenever re-rendered
// (This process does not affect rendering)
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
// 3. Set up the interval
useEffect(() => {
// If delay is null, stop the timer (Declarative pause feature)
if (delay === null) return;
const tick = () => {
// The timer always executes 'ref.current'.
// The ref always holds the latest callback!
savedCallback.current();
};
const id = setInterval(tick, delay);
return () => clearInterval(id);
}, [delay]); // Reset timer only when delay changes
}This code is a kind of magic. setInterval keeps calling the function tick, but tick internally refers to savedCallback.current. We just secretly swap savedCallback.current at render time.
3. Practical Application: Chris's Timer Resurrected
Now Chris can write code intuitively without worrying about complex useEffect and closures.
// Timer.tsx
import { useState } from 'react';
import { useInterval } from './useInterval';
function Timer() {
const [count, setCount] = useState(10);
const [isPlaying, setIsPlaying] = useState(true);
// β
Feels like using setInterval declaratively!
useInterval(() => {
setCount(count - 1);
}, isPlaying ? 1000 : null); // Passing null pauses it.
return (
<div>
<h1>{count}</h1>
<button onClick={() => setIsPlaying(!isPlaying)}>
{isPlaying ? 'Pause' : 'Resume'}
</button>
</div>
);
}As a bonus, he easily implemented a pause feature by passing null to delay. The code has become much more readable.
4. The Sibling: useTimeout
Using the same principle, useTimeout, which turns setTimeout into a hook, is also useful.
It is handy when something needs to execute exactly once after a delay, but you need to cancel it or change the time if conditions change in the meantime.
// useTimeout.ts
import { useEffect, useRef } from 'react';
export function useTimeout(callback: () => void, delay: number | null) {
const savedCallback = useRef(callback);
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
useEffect(() => {
if (delay === null) return;
const id = setTimeout(() => savedCallback.current(), delay);
return () => clearTimeout(id);
}, [delay]);
}Key Takeaways
We have mastered handling time. Now it's time to handle space (the screen).
"I want to hide the sidebar on mobile screens."
CSS media queries (@media) alone cannot control JavaScript logic (conditional rendering). Let's create a hook that detects screen size changes in real-time within JS.
Continuing in: "useMediaQuery & useWindowSize: Controlling Responsive Design with JavaScript."