59Implement useInterval() without stale closures
Problem
This counter looks right but gets stuck at 1:
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => setCount(count + 1), 1000);
return () => clearInterval(id);
}, []); // the callback only ever sees count = 0
return <h1>{count}</h1>;
}Implement useInterval(callback, delay) so that:
callbackruns everydelayms and always sees the latest props and state (no stale closure);- passing a new callback on every render does not restart the interval;
delay === nullpauses the interval, and changingdelayrestarts it with the new delay;- the interval is cleared when the component unmounts.
function Clock() {
const [count, setCount] = useState(0);
const [running, setRunning] = useState(true);
useInterval(() => setCount(count + 1), running ? 1000 : null);
return <button onClick={() => setRunning(!running)}>{count}</button>;
}Try it yourself
Write your solution, run it, then submit to check it against the tests.
Step by step
Build the solution one idea at a time.
- 1
Understand the stale closure
The effect in the example runs once, so the interval keeps the callback from the first render, where
countis 0. Every tick setscountto0 + 1. The callback is "stale": it closed over old state. - 2
Keep the latest callback in a ref
Store the callback in
savedCallback = useRef(callback)and update it after every render in an effect:savedCallback.current = callback. The ref always points at the newest callback, and updating it does not cause a render. - 3
Let the interval call through the ref
The interval runs
() => savedCallback.current(), a small function that never changes and always calls the latest callback. - 4
Restart only when the delay changes
Set up the interval in an effect that depends on
[delay]only. A new callback on every render no longer restarts the timer, which would reset its rhythm. - 5
Pause with null
When
delayisnull, return early without starting an interval. The cleanup of the previous effect already cleared the old one.
Approach
Separate the two things that change: the callback (changes every render, kept in a ref) and the timing (changes rarely, owned by an effect on delay). The interval calls the ref, so it always runs the latest code.
Solution
import { useEffect, useRef, useState } from "react";
function useInterval(callback, delay) {
const savedCallback = useRef(callback);
useEffect(() => {
savedCallback.current = callback;
});
useEffect(() => {
if (delay === null) return;
const id = setInterval(() => savedCallback.current(), delay);
return () => clearInterval(id);
}, [delay]);
}
// Preview: a counter you can pause.
function App() {
const [count, setCount] = useState(0);
const [running, setRunning] = useState(true);
useInterval(() => setCount(count + 1), running ? 500 : null);
return (
<div>
<h1 style={{ margin: 0 }}>{count}</h1>
<button onClick={() => setRunning(!running)}>{running ? "Pause" : "Resume"}</button>
</div>
);
}Complexity
Time: O(1) per render and per tick. Space: O(1).
Edge cases
- The callback always sees the latest state, even though the interval never restarts.
- A new callback each render does not reset the interval's rhythm.
delay = nullpauses; switching back to a number resumes.- Changing the delay restarts the interval with the new delay.
- Unmounting clears the interval.
Follow-up questions
Answer each one out loud first, then reveal the model answer.