Stale Closures in React: Every Render Is a Snapshot Your Callbacks Can't See
You'll learn why stale closures happen in React (it's not a React bug), how to spot them in useEffect, useCallback, and async code, and which fix to reach for: dependencies, functional setState, refs, or useEffectEvent.
TL;DR: A stale closure is a function that captured values from an old render and never got updated. React doesn't cause this. JavaScript closures do, and hooks make the timing visible. Every render is a snapshot. If your effect, timer, or memoized callback was created once and reads state from that moment, it will keep reading that moment forever. Fix it by re-syncing (dependency arrays), bypassing the closure (functional setState), or reading through a ref or useEffectEvent.
You set a setInterval inside useEffect with an empty dependency array. The counter on screen climbs to 47. The interval keeps logging 0. You add count to the deps. Now the interval restarts every second and the counter goes haywire. You remove count from deps, wrap it in a ref, sync the ref in another effect, and wonder why a language feature from 1995 needs three hooks to increment a number.
This is the stale closure problem. It's widely misunderstood because people treat it like a React bug when it's really a JavaScript behavior that React's rendering model makes impossible to ignore.
The misconception: "React hooks are broken" or "useEffect doesn't see updated state." Neither is true. React re-renders with fresh values every time. The function inside your effect was created during an earlier render and never replaced. It's still looking at that render's snapshot.
Once you internalize that, the fixes stop feeling like hacks and start feeling like choices.
What a Stale Closure Actually Is
A closure is a function that remembers variables from the scope where it was created. That's a feature. Stale means the scope is old.
function makeGreeter(name) {
return function greet() {
console.log(`Hello, ${name}`);
};
}
const greetAlice = makeGreeter('Alice');
greetAlice(); // "Hello, Alice"
// name inside greet is frozen at creation time.
// Nothing updates it unless you create a new function.React adds one twist: your component function runs again on every state change. Each run creates a new scope with new count, new query, new theme. But the setInterval callback you registered two minutes ago still closes over the count from the render when the effect ran.
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
// This `count` is whatever it was when THIS effect ran
console.log('interval sees:', count);
}, 1000);
return () => clearInterval(id);
}, []); // effect runs once → closure frozen forever
return (
<button onClick={() => setCount(c => c + 1)}>
{count}
</button>
);
}The UI shows 47. The interval logs 0. Both are "correct" from their respective points of view. The button's onClick handler is recreated every render, so it always sees fresh state. The interval callback was created once and wasn't.
That's the whole bug. Everything else is a variation.
The Mental Model People Get Wrong
Three beliefs cause most of the confusion:
"State is a variable that updates in place." It's not. count in render 1 and count in render 47 are different bindings in different function invocations. React keeps the latest value internally and hands you the current one each render. Old closures still point at old bindings.
"useEffect runs after every render." Only when its dependencies change (or on mount with []). An effect with [] runs once. The closure inside it is from that one render.
"Event handlers have stale closure problems too." Usually no. When you click a button, React calls the handler from the latest render. You get a fresh closure at interaction time. Stale closures bite in callbacks that outlive the render: timers, subscriptions, memoized functions passed to children, async continuations.
function Search() {
const [query, setQuery] = useState('');
// ✅ Fresh on every click, recreated each render
const handleClick = () => {
console.log(query);
};
// ❌ Stale: created once, lives until unmount
useEffect(() => {
const id = setInterval(() => {
console.log(query); // frozen at mount
}, 1000);
return () => clearInterval(id);
}, []);
return <button onClick={handleClick}>Log query</button>;
}Click the button after typing. handleClick logs the current query. The interval does not.
Pattern 1: The Timer That Can't Count
The canonical example. Broken three different ways, fixed three different ways.
Broken: empty deps
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1); // always count + 1 where count === 0
}, 1000);
return () => clearInterval(id);
}, []);
return <p>{count}</p>;
}setCount(0 + 1) runs every second. Counter stuck at 1.
Fix A: functional setState (best when next state depends only on previous)
useEffect(() => {
const id = setInterval(() => {
setCount(prev => prev + 1); // React gives you the real latest value
}, 1000);
return () => clearInterval(id);
}, []);No count in the closure. React's updater function receives the committed state directly. This is the cleanest fix when you're only deriving the next state from the previous one.
Fix B: add count to deps (when you need the actual value, not just prev + 1)
useEffect(() => {
const id = setInterval(() => {
setCount(count + increment); // needs both count AND increment
}, 1000);
return () => clearInterval(id);
}, [count, increment]); // effect restarts every tick, bad for intervalsThis works logically but restarts the interval on every count change. For a 1-second interval that increments by 1, you reset the timer every second. Sometimes that's fine. Often it's not.
Fix C: ref to read latest without restarting
function Counter() {
const [count, setCount] = useState(0);
const [increment, setIncrement] = useState(1);
const countRef = useRef(count);
const incrementRef = useRef(increment);
// ponytail: syncs on every render; upgrade path is useEffectEvent in React 19.2+
countRef.current = count;
incrementRef.current = increment;
useEffect(() => {
const id = setInterval(() => {
setCount(countRef.current + incrementRef.current);
}, 1000);
return () => clearInterval(id);
}, []); // interval never restarts, always reads fresh values
return (/* ... */);
}Writing to ref.current during render (not in an effect) is the pattern React docs use for keeping refs in sync. The interval stays stable. The refs always hold the latest values.
Pattern 2: useCallback With Empty Deps
This is where performance optimization and stale closures collide. You memoized the callback to stop child re-renders. You created a time capsule.
function Parent() {
const [count, setCount] = useState(0);
const increment = useCallback(() => {
setCount(count + 1); // count is always 0 inside this closure
}, []); // "stable" and wrong
return <MemoizedChild onIncrement={increment} />;
}
const MemoizedChild = React.memo(function Child({ onIncrement }) {
return <button onClick={onIncrement}>+</button>;
});Click the button ten times. Count stays at 1.
Fix: functional update in a stable callback
const increment = useCallback(() => {
setCount(prev => prev + 1);
}, []); // stable AND correctFix: include the dependency (stable only if child can handle it)
const increment = useCallback(() => {
setCount(count + 1);
}, [count]); // new function every count change → MemoizedChild re-renders anywayIf count is in the deps, useCallback recreates the function every time count changes. React.memo on the child sees a new prop and re-renders. You traded a stale closure for a memoization that does nothing.
The rule: useCallback with [] only works if the callback never reads reactive values, or reads them through functional updates, refs, or external stores with stable subscriptions.
Pattern 3: The Async Fetch That Applies Old Results
Stale closures and race conditions often show up together.
function UserSearch() {
const [query, setQuery] = useState('');
const [results, setResults] = useState<User[]>([]);
useEffect(() => {
if (!query) return;
fetch(`/api/users?q=${query}`)
.then(res => res.json())
.then(data => {
// If user typed "react" then "vue", both fetches are in flight.
// Whichever resolves LAST wins. Might be the stale "react" response.
setResults(data);
});
}, [query]);
return (/* ... */);
}The dependency array is correct here. The effect re-runs when query changes. But the old fetch's .then is still a closure that calls setResults. It's not stale in the "wrong count" sense. It's stale in the "this response is for an old query" sense.
Fix: AbortController (browser-native, cancel in-flight requests on cleanup)
useEffect(() => {
if (!query) return;
const controller = new AbortController();
fetch(`/api/users?q=${query}`, { signal: controller.signal })
.then(res => res.json())
.then(data => setResults(data))
.catch(err => {
if (err.name !== 'AbortError') throw err;
});
return () => controller.abort(); // cancel in-flight request on query change
}, [query]);Fix: request ID guard (when you can't abort)
useEffect(() => {
if (!query) return;
let ignore = false;
fetch(`/api/users?q=${query}`)
.then(res => res.json())
.then(data => {
if (!ignore) setResults(data);
});
return () => { ignore = true; };
}, [query]);The closure over ignore is fine because cleanup flips the flag. The .then still runs, but the guard prevents the stale write.
Pattern 4: Event Listeners in Effects
Same shape as the timer. Register once, read stale values forever.
function DragTracker() {
const [position, setPosition] = useState({ x: 0, y: 0 });
const [enabled, setEnabled] = useState(true);
useEffect(() => {
function onMove(e: PointerEvent) {
if (enabled) {
setPosition({ x: e.clientX, y: e.clientY });
}
}
window.addEventListener('pointermove', onMove);
return () => window.removeEventListener('pointermove', onMove);
}, []); // enabled is frozen at initial value
return (/* checkbox for enabled, dot at position */);
}Uncheck "enabled." The dot keeps following your cursor. enabled inside onMove is still true.
Fix: ref for the gate flag
const enabledRef = useRef(enabled);
enabledRef.current = enabled;
useEffect(() => {
function onMove(e: PointerEvent) {
if (enabledRef.current) {
setPosition({ x: e.clientX, y: e.clientY });
}
}
window.addEventListener('pointermove', onMove);
return () => window.removeEventListener('pointermove', onMove);
}, []);Fix: add enabled to deps (re-register listener)
useEffect(() => {
function onMove(e: PointerEvent) {
if (enabled) {
setPosition({ x: e.clientX, y: e.clientY });
}
}
window.addEventListener('pointermove', onMove);
return () => window.removeEventListener('pointermove', onMove);
}, [enabled]); // tears down and re-adds listener when enabled changesRe-registering is correct and often cheap. Don't reach for a ref just to avoid removing a listener unless profiling shows it's a problem.
Pattern 5: useEffect With Missing Dependencies
React's linter exists because this bug is silent.
function ChatRoom({ roomId }: { roomId: string }) {
const [serverUrl, setServerUrl] = useState('https://localhost:1234');
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => connection.disconnect();
}, []); // 🔴 roomId and serverUrl are stale after user changes them
}User switches rooms. Effect never re-runs. Still connected to the first room.
Fix: list every reactive value you read
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => connection.disconnect();
}, [serverUrl, roomId]);React docs are explicit: you don't "choose" your dependencies. If your effect reads it, it goes in the array. The linter isn't being pedantic. It's catching a real desync between your effect and your UI.
Pattern 6: The Debounced Search That Searches Old Queries
function Search() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const debouncedSearch = useCallback(
debounce((q: string) => {
fetch(`/api/search?q=${q}`)
.then(r => r.json())
.then(setResults);
}, 300),
[] // debounce instance is stable, but...
);
useEffect(() => {
debouncedSearch(query);
}, [query, debouncedSearch]);
}This one is actually fine IF debouncedSearch is stable and you pass query as an argument each call. The stale closure risk is inside the debounce implementation itself if it captures state:
// ❌ debounce closure captures `query` from render
const debouncedSearch = useCallback(
debounce(() => {
fetch(`/api/search?q=${query}`) // frozen query from when debounce was created
.then(r => r.json())
.then(setResults);
}, 300),
[]
);Fix: always pass values as arguments, never close over them
const debouncedSearch = useRef(
debounce((q: string) => {
fetch(`/api/search?q=${q}`)
.then(r => r.json())
.then(setResults);
}, 300)
).current;
useEffect(() => {
debouncedSearch(query);
}, [query]);Or recreate the debounced function when the search logic changes:
const debouncedSearch = useMemo(
() => debounce((q: string) => {
fetch(`/api/search?q=${q}`).then(r => r.json()).then(setResults);
}, 300),
[/* deps of the search logic, e.g. filters */]
);
useEffect(() => {
debouncedSearch(query);
return () => debouncedSearch.cancel();
}, [query, debouncedSearch]);Pattern 7: Custom Hooks That Leak Stale Values
Custom hooks are just functions. They close over render scope like everything else.
// ❌ consumers get a stale toggle forever
function useToggle(initial = false) {
const [on, setOn] = useState(initial);
const toggle = useCallback(() => {
setOn(on => !on); // functional update, actually fine
}, []);
const turnOn = useCallback(() => {
setOn(true); // fine, doesn't read on
}, []);
const logState = useCallback(() => {
console.log('current:', on); // ❌ stale on
}, []);
return { on, toggle, turnOn, logState };
}toggle is safe because it uses the functional form. logState is broken because it reads on with [] deps.
Fix: ref inside the custom hook
function useLatest<T>(value: T) {
const ref = useRef(value);
ref.current = value;
return ref;
}
function useToggle(initial = false) {
const [on, setOn] = useState(initial);
const onRef = useLatest(on);
const logState = useCallback(() => {
console.log('current:', onRef.current);
}, []);
const toggle = useCallback(() => setOn(v => !v), []);
return { on, toggle, logState };
}Any custom hook that returns a stable callback reading props or state should use functional updates, refs, or useEffectEvent internally.
Pattern 8: useMemo Closures
Less common, same mechanics.
function FilteredList({ items, minPrice }: Props) {
const [sortAsc, setSortAsc] = useState(true);
const sortedItems = useMemo(() => {
return items
.filter(item => item.price >= minPrice)
.sort((a, b) => sortAsc ? a.price - b.price : b.price - a.price);
}, [items, minPrice]); // 🔴 missing sortAsc, list doesn't re-sort on toggle
}useMemo caches a value and only recomputes when deps change. Missing sortAsc means the sort direction is frozen.
Fix: complete deps
const sortedItems = useMemo(() => {
return items
.filter(item => item.price >= minPrice)
.sort((a, b) => sortAsc ? a.price - b.price : b.price - a.price);
}, [items, minPrice, sortAsc]);Pattern 9: WebSocket and Subscription Handlers
Long-lived connections are stale closure magnets.
function PriceTicker({ symbol }: { symbol: string }) {
const [price, setPrice] = useState<number | null>(null);
const [showAlert, setShowAlert] = useState(false);
useEffect(() => {
const ws = new WebSocket(`wss://api.example.com/prices`);
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
setPrice(data[symbol]);
if (showAlert && data[symbol] > 100) {
notify('Price above 100!');
}
};
return () => ws.close();
}, [symbol]); // showAlert is stale when user toggles it
}symbol is in deps, so reconnecting on symbol change is correct. But showAlert is read inside onmessage without being a dep. Toggling the alert checkbox does nothing until you reconnect.
Fix: ref for the non-reactive toggle
const showAlertRef = useRef(showAlert);
showAlertRef.current = showAlert;
useEffect(() => {
const ws = new WebSocket(`wss://api.example.com/prices`);
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
setPrice(data[symbol]);
if (showAlertRef.current && data[symbol] > 100) {
notify('Price above 100!');
}
};
return () => ws.close();
}, [symbol]);You don't want to reconnect the WebSocket when showAlert changes. You want the handler to read the latest flag. That's the ref use case.
Pattern 10: useEffectEvent (React 19.2+)
Before useEffectEvent shipped as a stable API in React 19.2, the ref-sync pattern was the standard answer for "this effect should run once, but the callback needs fresh values." useEffectEvent formalizes it.
import { useEffect, useEffectEvent, useState } from 'react';
function ChatRoom({ roomId, muted }: { roomId: string; muted: boolean }) {
const onConnected = useEffectEvent(() => {
if (!muted) {
showNotification(`Connected to ${roomId}`);
}
});
useEffect(() => {
const connection = createConnection(roomId);
connection.on('connected', () => onConnected());
connection.connect();
return () => connection.disconnect();
}, [roomId]); // muted is NOT a dep. Reconnecting on mute toggle would be wrong.
}onConnected always sees the latest muted and roomId when it's called, but it doesn't cause the effect to re-run when they change.
Timer with useEffectEvent
function Timer() {
const [count, setCount] = useState(0);
const [increment, setIncrement] = useState(1);
const onTick = useEffectEvent(() => {
setCount(count + increment);
});
useEffect(() => {
const id = setInterval(onTick, 1000);
return () => clearInterval(id);
}, []);
return (/* UI to change increment */);
}Change increment while the timer runs. The counter immediately uses the new step size. The interval never restarts.
Reusable useInterval hook
function useInterval(callback: () => void, delay: number | null) {
const onTick = useEffectEvent(callback);
useEffect(() => {
if (delay === null) return;
const id = setInterval(onTick, delay);
return () => clearInterval(id);
}, [delay]);
}
// Usage: increment changes without resetting the interval
function Counter({ step }: { step: number }) {
const [count, setCount] = useState(0);
useInterval(() => {
setCount(c => c + step);
}, 1000);
return <p>{count}</p>;
}What useEffectEvent is NOT for
// ❌ Don't use it to hide deps that SHOULD re-trigger the effect
const logVisit = useEffectEvent(() => {
analytics.log(pageUrl);
});
useEffect(() => {
logVisit();
}, []); // pageUrl changes, effect doesn't re-run, visits aren't loggedIf a value changing should cause your effect to re-synchronize (reconnect, refetch, re-subscribe), it belongs in the dependency array. useEffectEvent is for logic inside an effect that should not be reactive. Notifications, logging, reading toggle flags in callbacks. Not for skipping deps you're lazy about.
Also: Effect Events can only be called from effects. Don't pass them to children or use them in onClick.
Pattern 11: Ref Pattern vs useLayoutEffect Sync
The pre-React-19 pattern you'll still see in codebases:
function ChatRoom({ roomId, theme }: { roomId: string; theme: string }) {
const themeRef = useRef(theme);
useLayoutEffect(() => {
themeRef.current = theme;
}, [theme]);
useEffect(() => {
const connection = createConnection(roomId);
connection.on('connected', () => {
showNotification('Connected!', themeRef.current);
});
connection.connect();
return () => connection.disconnect();
}, [roomId]);
}useLayoutEffect guarantees the ref is updated before paint, which matters if the effect fires synchronously during the same commit. For most cases, assigning ref.current = value during render works and is what React's own docs recommend now.
// Simpler, usually sufficient
const themeRef = useRef(theme);
themeRef.current = theme;Pattern 12: Stale Closures in useReducer
Less talked about, same rules.
function TodoList() {
const [todos, dispatch] = useReducer(reducer, []);
const [filter, setFilter] = useState<'all' | 'done'>('all');
const markAllDone = useCallback(() => {
// ❌ `filter` is stale, only marks items matching the filter at creation time
todos
.filter(t => filter === 'all' || t.done)
.forEach(t => dispatch({ type: 'MARK_DONE', id: t.id }));
}, []); // missing todos and filter
return (/* ... */);
}Fix: pass intent through the action, not through closure
const markAllDone = useCallback(() => {
dispatch({ type: 'MARK_ALL_DONE', filter });
}, [filter]); // or dispatch({ type: 'MARK_ALL_DONE' }) and let reducer handle allReducer actions should carry the data they need. Don't rely on the dispatching closure to close over state that might be old.
Pattern 13: Context + Stable Callback
function DataProvider({ children }: { children: React.ReactNode }) {
const [data, setData] = useState<Item[]>([]);
const [filter, setFilter] = useState('');
const api = useMemo(() => ({
addItem: (item: Item) => {
// ❌ closes over `data` from when useMemo last ran
setData([...data, item]);
},
setFilter,
}), []); // empty deps = data is always initial []
return (
<DataContext.Provider value={api}>
{children}
</DataContext.Provider>
);
}Fix: functional update in the stable API
const api = useMemo(() => ({
addItem: (item: Item) => {
setData(prev => [...prev, item]);
},
setFilter,
}), []); // setData and setFilter are stable from useStatePattern 14: The isMounted Guard (Stale in a Different Way)
function Profile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
let isMounted = true;
fetchUser(userId).then(data => {
if (isMounted) setUser(data);
});
return () => { isMounted = false; };
}, [userId]);
}This isn't a stale state read. The closure over isMounted works because cleanup mutates it. But watch the footgun: if you capture userId in a nested async function without it being a dep of an outer effect, you get the classic stale ID problem.
// ❌ outer effect runs once, inner fetches always use initial userId
useEffect(() => {
const controller = new AbortController();
async function load() {
const data = await fetchUser(userId, { signal: controller.signal });
setUser(data);
}
load();
return () => controller.abort();
}, []); // userId missingPattern 15: Strict Mode Makes It Look Worse
In development, React 18+ runs setup → cleanup → setup to surface missing cleanups. Your interval fires twice on mount. If you're logging closures, you'll see two registrations and think something is doubly stale.
useEffect(() => {
console.log('setup, count is', count);
const id = setInterval(() => console.log('tick, count is', count), 1000);
return () => {
console.log('cleanup');
clearInterval(id);
};
}, []);Dev console: setup (0), cleanup, setup (0), tick (0), tick (0)... Production: setup (0), tick (0), tick (0)...
The stale value isn't caused by Strict Mode. But Strict Mode exposes effects that don't clean up properly. If your interval isn't cleared, you get duplicate timers and truly bizarre behavior.
The Decision Tree
When you suspect a stale closure, ask these questions in order:
1. Is the callback recreated every render (event handler)?
→ Probably fine. Handlers are fresh at interaction time.
2. Does the callback outlive the render (timer, listener, memoized prop, async)?
→ Stale closure territory. Continue.
3. Are you only computing next state from previous state?
→ Functional setState: setX(prev => ...)
4. Should the effect re-synchronize when this value changes?
(reconnect, refetch, re-subscribe)
→ Add to dependency array.
5. Should the effect stay running but read the latest value?
(timer tick, listener callback, notification preference)
→ useEffectEvent (React 19.2+) or ref sync.
6. Are you passing a stable callback to a memoized child?
→ useCallback + functional setState, or ref for reads.Quick Reference
| Situation | Broken pattern | Fix |
|---|---|---|
| Interval incrementing state | setCount(count + 1) with [] | setCount(c => c + 1) |
| Interval reading multiple values | Missing deps, can't restart timer | Ref sync or useEffectEvent |
| Memoized child callback | useCallback(() => setX(x + 1), []) | Functional update in callback |
| Effect connecting to external system | Missing roomId in deps | Add all read values to deps |
| Listener reading a toggle flag | [] deps, reads enabled | Ref or useEffectEvent, or add enabled to deps |
| Async fetch race | No abort/guard on query change | AbortController or ignore flag |
| Debounced function | Closes over query internally | Pass query as argument |
| Custom hook stable callback | Reads state with [] deps | useLatest ref or functional update |
| WebSocket handler | Reads state not in effect deps | Ref for non-reconnecting values |
| Context stable API | setData([...data, item]) in useMemo([]) | setData(prev => [...prev, item]) |
What I Got Wrong for Years
I used to think the exhaustive-deps ESLint rule was overzealous. I'd disable it, add a comment like "intentional," and ship. Then a chat room wouldn't switch when the user picked a new room, or a notification would fire with the wrong theme, or a React.memo child would call a callback that incremented to 1 and stopped.
The rule isn't pedantic. It's asking one question: does this effect need to re-synchronize when that value changes? If yes, add the dep. If no, you need a different mechanism to read the latest value (functional update, ref, useEffectEvent). There isn't a third option where you just hope JavaScript notices the variable changed.
Stale closures aren't a React footgun. They're what happens when a function outlives the render that created it. React just makes renders frequent enough that you'll hit it. Once you see every render as a snapshot, you stop fighting the model and start picking the right escape hatch.
The counter stuck at 1 isn't mysterious. It's a function from render zero, doing exactly what you told it to do. The fix is either give it a new function (deps), stop asking it what count is (functional setState), or hand it a mailbox that always has today's mail (ref or useEffectEvent).
Pick the one that matches whether your effect should re-run. That's the whole decision.