Why Your React Polling Hook Can Leak State for Minutes
Discover how a stale closure in a React polling loop can keep old data alive, slow down grids, and how to fix it with proper cleanup and refs.

What actually went wrong
In a typical data‑grid dashboard you might start a setInterval to fetch fresh rows every few seconds. The naive implementation often looks like this:
useEffect(() => {
const id = setInterval(() => {
fetchRows(filter).then(setRows);
}, 5000);
return () => clearInterval(id);
}, []); // <-- empty deps!The empty dependency array means the effect runs only once, capturing the filter value that existed at mount time. As the user changes filters, the interval keeps calling the old API with stale parameters. The UI keeps rendering rows from twenty minutes ago, while the network request count stays constant, so you never see a red flag in Chrome DevTools.
Why the closure matters
JavaScript closures capture the whole lexical environment, not just the variables you think you need. When the component re‑renders, a new filter variable is created, but the interval callback still holds a reference to the original one. That reference never gets garbage‑collected because the interval itself lives on the global timer queue.
In a long‑running page—think an ops team leaving the dashboard open for hours—the memory footprint of those stale objects accumulates. The grid component keeps receiving the same old data, React diffing large arrays that never change, and the browser’s main thread gets hammered.
Detecting the leak in the wild
- Performance tab shows a steady rise in JS heap size after ~15 min of use.
- Network tab shows constant request rate, but the payload contains the same filter values.
- Scrolling becomes jittery even though DOM node count stays flat.
All three symptoms point to a closure that never updates.
Practical fixes
Two patterns solve the problem without sacrificing the simplicity of a polling hook.
1. Include the changing values in the dependency array
useEffect(() => {
const id = setInterval(() => {
fetchRows(filter).then(setRows);
}, 5000);
return () => clearInterval(id);
}, [filter]); // re‑create interval when filter changesThis ensures the interval always uses the latest filter, but it also tears down and recreates the timer on every change, which can be noisy if the filter updates rapidly.
2. Use a mutable ref for the latest state
const filterRef = useRef(filter);
useEffect(() => { filterRef.current = filter; }, [filter]);
useEffect(() => {
const id = setInterval(() => {
fetchRows(filterRef.current).then(setRows);
}, 5000);
return () => clearInterval(id);
}, []);The interval callback reads filterRef.current at execution time, so it always sees the newest value without recreating the timer.
When to prefer one over the other
If your polling interval is cheap and your filter changes infrequently, the dependency‑array approach is fine and keeps the code easy to read. For high‑frequency UI controls—sliders, type‑ahead filters—a ref‑based solution avoids the overhead of constantly clearing and resetting timers.
Also remember to clear the interval in the cleanup function; otherwise the timer lives on even after the component unmounts, leaking memory in single‑page apps.
Takeaway
Closures are powerful, but they’re also a common source of hidden state leaks in React. Always ask yourself: "Is this async callback holding onto stale variables?" If the answer is yes, bring in a ref or adjust your dependencies. The extra line of code saves you minutes of debugging and keeps your dashboards snappy for the whole shift.