Initializing Enclave...

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 useEffect closes 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/useMemo before 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 abortController or stale setState, 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 removeEventListener is called with a different function reference than addEventListener, 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.

Related Diagnostics

"Part of the Syntax Utility Matrix."

View all 153 Syntax Tools →