Core Definition
A stale closure occurs when a function 'closes over' (captures) variables from its surrounding scope, but those variables change after the closure is created. In React, this often happens withuseEffect() or useCallback() when dependencies are missing, causing the effect or callback to use an outdated value of a state or prop.Key Characteristics
- Outdated Values: the primary symptom is that your function uses a value that is not the most current, leading to unexpected behavior.
- Missing Dependencies: most commonly caused by omitting a dependency in
useEffectoruseCallbackthat the effect/callback actually relies on. - Linter Warnings: the
eslint-plugin-react-hooks(specificallyexhaustive-deps) is designed to catch these issues and will warn you about missing dependencies. - Solutions: include all dependencies, use functional updates for state, or use
useReffor values that don't trigger re-renders.
Avoiding Stale Closures
Always include every value you reference insideuseEffect() or useCallback() in their dependency arrays so the function updates when those values change. If you need to keep track of a value that changes frequently but shouldn't trigger re-renders, store it in a useRef() so you can read the latest value without causing the effect or callback to run unnecessarily.Examples
- Stale Count: in
useEffect()that logs an outdatedcountvalue due to a missing dependency. - Corrected useEffect with Dependency: fixing the stale closure by including
countin the dependency array. - Using Functional Updates: updating state based on its previous value to avoid needing the state in the dependency array.
Code Examples
- https://codesandbox.io/p/sandbox/nice-mayer-5cz9t5
Stale Count in
useEffect// App.tsx 'use client' import { useEffect, useState } from 'react' export default function App() { const [count, setCount] = useState(0) // biome-ignore lint/correctness/useExhaustiveDependencies: <on-prupose> useEffect(() => { const interval = setInterval(() => { console.log('Stale count: ', count) // 'count' is always 0 }, 1000) return () => clearInterval(interval) }, []) // Missing 'count' dependency return ( <button type="button" onClick={() => setCount(count + 1)}> Increment: {count} </button> ) } - https://codesandbox.io/p/sandbox/stoic-clarke-kdplvy
Corrected
useEffectwith Dependency// App.tsx 'use client' import { useEffect, useState } from 'react' export default function CorrectedCounter() { const [count, setCount] = useState(0) useEffect(() => { const interval = setInterval(() => { console.log('Current count: ', count) }, 1000) return () => clearInterval(interval) }, [count]) // 'count' is now a dependency return ( <button type="button" onClick={() => setCount(count + 1)}> Increment: {count} </button> ) } - https://codesandbox.io/p/sandbox/jovial-hill-myw4fk
Using Functional Updates
// App.tsx 'use client' import { useEffect, useState } from 'react' export default function App() { const [count, setCount] = useState(0) useEffect(() => { const interval = setInterval(() => { setCount((prevCount) => prevCount + 1) }, 1000) return () => clearInterval(interval) }, []) // No 'count' dependency needed return <div>Count: {count}</div> }