How to Fix React useMemo Stale Closure: Dependency Array Debugging Guide
Threat/Impact Level: HIGH | Downtime Risk: MEDIUM | Time to Fix: 5–15 mins
TL;DR
- What broke:
useMemocaptured a variable from an outer scope at render time and never re-captured it — the memoized computation runs against a frozen, stale reference while the real value has moved on. - How to fix it: Every variable read inside the
useMemocallback that can change between renders must appear in the dependency array. No exceptions. - Use our Client-Side Sandbox below to auto-refactor this — paste your component and it will diff the correct dependency array instantly.
The Incident (What Does the Error Mean?)
There is no thrown exception. That is what makes this brutal. The symptom is silent wrong data.
Typical observable failure:
// User changes `filters` state. Table does not re-sort.
// Console is clean. React DevTools shows memo cache hit.
// The memoized `sortedData` is still sorted by the OLD filter value.
React's useMemo caches the return value of your callback and only recomputes when the dependency array changes. If a variable used inside the callback is absent from the array, React never knows it changed. The closure formed at the first render holds a stale reference forever — or until something else triggers a recompute by accident.
Immediate consequence: Users see incorrect derived state. Filters don't apply. Sorts don't update. Aggregations return wrong totals. The bug is non-deterministic in appearance because unrelated re-renders can accidentally bust the cache and temporarily fix the symptom.
The Attack Vector / Blast Radius
This is a silent data integrity failure. The blast radius scales with how downstream the memoized value is:
- Memoized filter/sort logic → wrong rows rendered to users, potential data leakage if row-level permissions are derived from the stale value.
- Memoized permission checks → a user sees UI elements they should not, or cannot access elements they should. This crosses from a performance bug into a security-adjacent bug.
- Memoized API request parameters → stale params sent to backend, wrong data fetched, cache poisoning in downstream
React Query/SWRlayers. - Cascading memo chains →
useMemo AfeedsuseMemo BfeedsuseEffect C. One stale dep poisons the entire reactive graph. TheuseEffectfires with correct deps but consumes stale computed input — extremely hard to trace.
The worst-case scenario: the bug only manifests in production because dev-mode double-invocation or HMR accidentally busts the stale cache during development.
How to Fix It (The Solution)
Basic Fix — Add the Missing Dependency
- const sortedData = useMemo(() => {
- return data.slice().sort((a, b) => {
- return a[sortKey] > b[sortKey] ? 1 : -1;
- });
- }, [data]); // ❌ sortKey is read inside but missing from deps
+ const sortedData = useMemo(() => {
+ return data.slice().sort((a, b) => {
+ return a[sortKey] > b[sortKey] ? 1 : -1;
+ });
+ }, [data, sortKey]); // ✅ all referenced variables declared
Enterprise Best Practice — Enforce with eslint-plugin-react-hooks
Manual auditing does not scale. The exhaustive-deps ESLint rule makes missing dependencies a hard build error.
// .eslintrc.js
rules: {
- 'react-hooks/exhaustive-deps': 'warn', // ❌ warn is ignored under deadline pressure
+ 'react-hooks/exhaustive-deps': 'error', // ✅ breaks CI on any stale closure
}
For complex objects causing unnecessary recomputes after fixing deps:
- const config = { threshold: props.threshold, mode: props.mode };
- const result = useMemo(() => compute(config), [config]); // ❌ new object ref every render
+ const result = useMemo(
+ () => compute({ threshold: props.threshold, mode: props.mode }),
+ [props.threshold, props.mode] // ✅ primitive deps, stable references
+ );
If the dependency is a function, stabilize it first:
- const process = (item) => transform(item, multiplier);
- const processed = useMemo(() => data.map(process), [data]); // ❌ process is unstable
+ const process = useCallback(
+ (item) => transform(item, multiplier),
+ [multiplier] // ✅ stable reference, safe to include in useMemo deps
+ );
+ const processed = useMemo(() => data.map(process), [data, process]); // ✅
💡 Tired of pasting proprietary configs into ChatGPT? Generic AI tools log your company's ARNs, DB strings, and private keys. StackEngine is a zero-backend, pure Client-Side WASM utility. Drop your failing config into the sandbox above. We redact your secrets locally in the browser and auto-generate the refactored code using your own API key.
Prevention in CI/CD
This class of bug must never reach a PR review. Gate it at the toolchain level.
1. ESLint exhaustive-deps as a blocking CI step
# .github/workflows/lint.yml
- name: Lint hooks
run: eslint --rule '{"react-hooks/exhaustive-deps": "error"}' src/
A non-zero exit code blocks merge. No exceptions for // eslint-disable without a mandatory code comment explaining the intentional omission.
2. React Compiler (React 19+)
The React Compiler (formerly React Forget) automatically infers and inserts correct memoization. If you are on React 19, enable it:
// babel.config.js
+ plugins: ['babel-plugin-react-compiler']
This eliminates the entire dependency array authoring problem for most cases.
3. Pre-commit hook via lint-staged
// package.json
"lint-staged": {
"src/**/*.{ts,tsx}": ["eslint --max-warnings=0"]
}
Blocks the commit locally before it ever hits CI.
4. Custom OPA / Semgrep rule for monorepos
For large teams where ESLint config drift is a risk, enforce via Semgrep:
# semgrep rule: no-usememo-without-exhaustive-deps
rules:
- id: usememo-stale-closure-risk
patterns:
- pattern: useMemo(() => { ... $VAR ... }, [...])
message: "Manually verify $VAR is in dependency array. Enable exhaustive-deps ESLint rule."
severity: WARNING
languages: [typescript, javascript]