How to Fix React useEffect Missing Dependency Warning: Stale Closures & Infinite Loop Debugging Guide
Bug Severity: HIGH | UX/User Impact: SEVERE | Time to Fix: 5 mins
TL;DR
- What broke: Your
useEffectcloses over a variable or function that isn't listed in its dependency array. React's stale closure means the effect sees an outdated value from a previous render — silently. - How to fix it: Add every referenced variable/function to the dependency array, or stabilize function references with
useCallback/useMemobefore including them. - Action: Use our Client-Side Sandbox above to paste your component and auto-refactor the dependency array without sending your code to any external server.
The Incident (What Does the Error Mean?)
ESLint outputs this at build time or in your editor:
React Hook useEffect has a missing dependency: 'userId'.
Either include it or remove the dependency array. react-hooks/exhaustive-deps
This fires from the eslint-plugin-react-hooks exhaustive-deps rule. The immediate consequence: your effect runs with a snapshot of userId from the render it was first created in, not the current render. If userId changes, the effect does not re-run. Your UI shows data for the wrong user. No error is thrown. No warning appears at runtime. It fails silently in production.
The Blast Radius
This is not a cosmetic lint warning. Stale closures in useEffect cause:
- Wrong data displayed to the wrong user — the classic session-switch bug where user B sees user A's fetched data because the fetch never re-ran.
- Memory leaks and race conditions — an async fetch inside the effect captures a stale
abortControlleror stalesetState, causing updates on unmounted components. - Infinite re-render loops — the opposite failure mode: a developer adds an unstabilized object or function literal to the dependency array to silence the lint warning, causing the effect to fire on every render because the reference changes every time.
- Broken event listener cleanup — a stale callback reference means
removeEventListeneris called with a different function reference thanaddEventListener, so the listener is never actually removed.
The blast radius scales with component complexity. In a data-fetching component at the top of your tree, one missing dependency can corrupt the entire page's data layer.
How to Fix It
Basic Fix — Add the Missing Dependency
The simplest case: you reference a primitive value (userId, count, isEnabled) inside the effect but forgot to list it.
useEffect(() => {
- fetchUserData(userId);
-}, []); // stale: userId never updates the effect
+ fetchUserData(userId);
+}, [userId]); // correct: effect re-runs when userId changes
Enterprise Best Practice — Stabilize Function References with useCallback
The dangerous pattern: you add a function to the dependency array, the function is defined inline in the component body, its reference changes every render, and you get an infinite loop.
- const fetchData = async () => {
- const res = await api.get(`/users/${userId}`);
- setData(res.data);
- };
-
- useEffect(() => {
- fetchData();
- }, [fetchData]); // infinite loop — fetchData is a new reference every render
+ const fetchData = useCallback(async () => {
+ const res = await api.get(`/users/${userId}`);
+ setData(res.data);
+ }, [userId]); // fetchData reference only changes when userId changes
+
+ useEffect(() => {
+ fetchData();
+ }, [fetchData]); // stable: effect fires only when fetchData (i.e., userId) changes
For object dependencies, memoize with useMemo or restructure to pass primitives. Never pass a raw object literal {} or array [] into a dependency array — they are new references on every render.
- useEffect(() => {
- initChart({ width: 800, height: 600 });
- }, [{ width: 800, height: 600 }]); // new object every render = infinite loop
+ const chartConfig = useMemo(() => ({ width: 800, height: 600 }), []);
+
+ useEffect(() => {
+ initChart(chartConfig);
+ }, [chartConfig]); // stable reference
💡 Tired of pasting proprietary configs into ChatGPT? Generic AI tools log your component code, API endpoints, and auth tokens. StackEngine is a zero-backend, pure Client-Side WASM utility. Drop your failing component into the sandbox above. We analyze your dependency array locally in the browser and auto-generate the refactored code using your own API key.
Prevention in CI/CD
Do not rely on developers catching this manually in their editor. Enforce it at the pipeline level.
1. ESLint with react-hooks/exhaustive-deps set to error (not warn)
In your .eslintrc.json:
{
"plugins": ["react-hooks"],
"rules": {
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "error"
}
}
Setting it to warn means it ships. Set it to error so the build fails.
2. Block merges in CI
In your GitHub Actions / GitLab CI pipeline:
- name: Lint
run: npx eslint src/ --max-warnings=0
--max-warnings=0 ensures zero warnings pass. Any exhaustive-deps violation blocks the PR merge.
3. Pre-commit hook via Husky + lint-staged
"lint-staged": {
"src/**/*.{ts,tsx}": ["eslint --max-warnings=0"]
}
This catches the violation before it ever reaches CI, at commit time on the developer's machine.
4. TypeScript strict mode
While not a direct fix, strict: true in tsconfig.json forces explicit typing that makes dependency relationships visible and reduces the chance of accidentally referencing stale values through implicit any.