Fix: React Hook useEffect has a missing dependency warning
Quick Answer
How to fix the React Hook useEffect has a missing dependency warning — covers the exhaustive-deps rule, useCallback, useMemo, refs, proper fetch patterns, and when to safely suppress the lint warning.
The Error
You open your browser console or run your linter and see this warning:
React Hook useEffect has a missing dependency: 'fetchData'. Either include it or remove the dependency array. (react-hooks/exhaustive-deps)Or one of its variants:
React Hook useEffect has missing dependencies: 'userId' and 'loadUser'. Either include them or remove the dependency array. (react-hooks/exhaustive-deps)React Hook useCallback has a missing dependency: 'count'. Either include it or remove the dependency array. (react-hooks/exhaustive-deps)The warning comes from the react-hooks/exhaustive-deps ESLint rule, which ships with eslint-plugin-react-hooks. It fires whenever a value used inside useEffect, useCallback, or useMemo is not listed in the dependency array.
Why This Happens
React’s dependency array tells the framework when to re-run the effect. If you reference a variable inside the effect but leave it out of the array, your effect captures a stale closure — it reads an old version of that variable instead of the current one.
Here is a concrete example:
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then((res) => res.json())
.then((data) => setUser(data));
}, []); // ← missing 'userId'
return <div>{user?.name}</div>;
}The effect runs once on mount because the dependency array is empty. When userId changes from 1 to 2, the effect does not re-run. The component keeps showing user 1’s data even though the prop already changed. This is the stale closure problem.
The exhaustive-deps rule exists to catch exactly this class of bug. It is not a style preference — it prevents real, hard-to-debug data inconsistencies in your application.
How the dependency array actually works
Every render creates a new scope with fresh variables. When React compares the current dependency array to the previous one using Object.is, it decides whether the effect needs to run again. Omitting a dependency means React never sees it change, so the effect keeps using the value from the render where it last ran.
This mechanism is fundamental to how Hooks work. Fighting it leads to bugs. Working with it leads to correct, predictable components. If you have hit this warning before while dealing with infinite re-renders, the root cause is often the same: a misunderstanding of how the dependency array drives effect execution.
Fix 1: Add the Missing Dependency
The simplest fix is to do what the warning says — add the missing value to the array:
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then((res) => res.json())
.then((data) => setUser(data));
}, [userId]); // ✅ userId included
return <div>{user?.name}</div>;
}Now the effect re-runs whenever userId changes, which is the correct behavior. The component always shows the right user.
This is the right fix in most cases. Before reaching for any other solution, ask yourself: “Should this effect re-run when this value changes?” If the answer is yes, add it to the array and move on.
Fix 2: Move the Function Inside useEffect
A common source of the warning is referencing a function defined outside the effect:
function SearchResults({ query }) {
const [results, setResults] = useState([]);
// ❌ Defined outside the effect
const fetchResults = async () => {
const res = await fetch(`/api/search?q=${query}`);
const data = await res.json();
setResults(data);
};
useEffect(() => {
fetchResults();
}, [query]); // Warning: missing dependency 'fetchResults'
return <ul>{results.map((r) => <li key={r.id}>{r.title}</li>)}</ul>;
}The function fetchResults is recreated on every render, so adding it to the dependency array would cause the effect to run on every render. The clean solution is to move the function inside the effect:
function SearchResults({ query }) {
const [results, setResults] = useState([]);
useEffect(() => {
// ✅ Function defined inside the effect
const fetchResults = async () => {
const res = await fetch(`/api/search?q=${query}`);
const data = await res.json();
setResults(data);
};
fetchResults();
}, [query]); // No warning — query is the only external dependency
return <ul>{results.map((r) => <li key={r.id}>{r.title}</li>)}</ul>;
}When the function lives inside the effect, it is not an external dependency. The linter only cares about values from the component scope that the effect closes over. Moving the function in eliminates the variable entirely.
Pro Tip: Moving functions inside
useEffectis the recommended approach in the React docs. It makes the data flow explicit — you can see exactly which props and state the effect depends on without jumping between different parts of the component.
Fix 3: Stabilize Functions with useCallback
Sometimes you cannot move the function inside the effect because it is used in multiple places:
function Dashboard({ teamId }) {
const [members, setMembers] = useState([]);
const loadMembers = async () => {
const res = await fetch(`/api/teams/${teamId}/members`);
const data = await res.json();
setMembers(data);
};
useEffect(() => {
loadMembers();
}, [loadMembers]); // Warning or infinite loop — loadMembers changes every render
return (
<div>
<MemberList members={members} />
<button onClick={loadMembers}>Refresh</button>
</div>
);
}If you add loadMembers to the array, the effect runs on every render because the function reference changes each time. Wrap it with useCallback to stabilize the reference:
function Dashboard({ teamId }) {
const [members, setMembers] = useState([]);
const loadMembers = useCallback(async () => {
const res = await fetch(`/api/teams/${teamId}/members`);
const data = await res.json();
setMembers(data);
}, [teamId]); // Only changes when teamId changes
useEffect(() => {
loadMembers();
}, [loadMembers]); // ✅ Stable reference, re-runs only when teamId changes
return (
<div>
<MemberList members={members} />
<button onClick={loadMembers}>Refresh</button>
</div>
);
}useCallback memoizes the function so it only gets a new reference when its own dependencies change. The effect now correctly re-runs when teamId changes and stays stable otherwise.
If you have seen the too many re-renders error, unstabilized function dependencies are a frequent cause. useCallback breaks that cycle.
Fix 4: Stabilize Object and Array Dependencies with useMemo
Objects and arrays are compared by reference in JavaScript. Even if two objects have identical contents, they are not equal:
{ page: 1 } === { page: 1 } // false
[1, 2, 3] === [1, 2, 3] // falseThis means an object or array created during render is a new reference every time, which triggers the effect on every render:
function ProductList({ category }) {
const [products, setProducts] = useState([]);
// ❌ New object every render
const filters = { category, inStock: true };
useEffect(() => {
fetch('/api/products', {
method: 'POST',
body: JSON.stringify(filters),
})
.then((res) => res.json())
.then((data) => setProducts(data));
}, [filters]); // Runs every render — filters is always a new object
return <div>{products.map((p) => <span key={p.id}>{p.name}</span>)}</div>;
}This pattern causes infinite loops in useEffect because every render creates a new filters object, which triggers the effect, which calls setProducts, which causes a re-render, and the cycle repeats.
Use useMemo to stabilize the reference:
function ProductList({ category }) {
const [products, setProducts] = useState([]);
// ✅ Same reference unless category changes
const filters = useMemo(() => ({ category, inStock: true }), [category]);
useEffect(() => {
fetch('/api/products', {
method: 'POST',
body: JSON.stringify(filters),
})
.then((res) => res.json())
.then((data) => setProducts(data));
}, [filters]); // Only re-runs when category changes
return <div>{products.map((p) => <span key={p.id}>{p.name}</span>)}</div>;
}Alternatively, destructure the object and depend on primitive values instead:
useEffect(() => {
const filters = { category, inStock: true };
fetch('/api/products', {
method: 'POST',
body: JSON.stringify(filters),
})
.then((res) => res.json())
.then((data) => setProducts(data));
}, [category]); // ✅ Primitive value — stable comparisonThis second approach is often cleaner. Move the object construction inside the effect and depend on the individual primitive values. No useMemo needed.
Fix 5: Use a Ref for Values You Do Not Want to Trigger Re-runs
Sometimes you genuinely need to read a value inside an effect without re-running the effect when that value changes. The most common case is an event handler or a callback prop that should not trigger a refetch:
function ChatRoom({ roomId, onMessage }) {
useEffect(() => {
const connection = createConnection(roomId);
connection.on('message', (msg) => {
onMessage(msg); // Uses onMessage but shouldn't re-run effect
});
connection.connect();
return () => connection.disconnect();
}, [roomId, onMessage]); // Adding onMessage causes reconnects when parent re-renders
}Every time the parent re-renders, onMessage gets a new reference (unless the parent wraps it in useCallback). This causes the chat connection to disconnect and reconnect unnecessarily. Use a ref to hold the latest value:
function ChatRoom({ roomId, onMessage }) {
const onMessageRef = useRef(onMessage);
// Keep the ref up to date
useEffect(() => {
onMessageRef.current = onMessage;
});
useEffect(() => {
const connection = createConnection(roomId);
connection.on('message', (msg) => {
onMessageRef.current(msg); // Always calls the latest version
});
connection.connect();
return () => connection.disconnect();
}, [roomId]); // ✅ Only reconnects when roomId changes
}The ref gives you a mutable container that persists across renders. The effect reads onMessageRef.current at call time, so it always gets the latest callback without needing it in the dependency array.
Common Mistake: Do not use refs to avoid adding state or props that genuinely affect the effect’s behavior. Refs are an escape hatch for values like event callbacks, logging functions, or analytics trackers — things that should not influence when the effect runs. If a value determines what the effect does (like a
userIdfor a fetch), it belongs in the dependency array.
Fix 6: Proper Patterns for Fetch Calls in useEffect
Data fetching is where the missing dependency warning shows up most often. Here is a robust pattern that handles the dependency correctly, prevents race conditions, and avoids updating unmounted components:
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
const fetchUser = async () => {
setLoading(true);
setError(null);
try {
const res = await fetch(`/api/users/${userId}`, {
signal: controller.signal,
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const data = await res.json();
setUser(data);
} catch (err) {
if (err.name !== 'AbortError') {
setError(err.message);
}
} finally {
setLoading(false);
}
};
fetchUser();
return () => controller.abort();
}, [userId]); // ✅ All dependencies listed, cleanup handles race conditions
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return <div>{user?.name}</div>;
}Key points in this pattern:
userIdis in the dependency array. The effect re-runs when it changes.AbortControllercancels the previous request when the effect re-runs. This prevents a race condition where a slow request for user 1 resolves after a fast request for user 2, overwriting the correct data.- The
AbortErrorcheck prevents setting error state for intentionally cancelled requests. setUserandsetLoadingare not in the dependency array because React guarantees that state setter functions are stable across renders.
What about custom hooks?
If you fetch data in multiple components, extract the pattern into a custom hook:
function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
const doFetch = async () => {
setLoading(true);
try {
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
setData(json);
} catch (err) {
if (err.name !== 'AbortError') setError(err.message);
} finally {
setLoading(false);
}
};
doFetch();
return () => controller.abort();
}, [url]); // ✅ url is the only dependency
return { data, loading, error };
}The caller passes a URL string, which is a primitive. No referential equality issues. No missing dependency warnings.
Fix 7: When to Legitimately Suppress the Warning
In rare cases, suppressing the warning with eslint-disable is the right call. But this should be your last resort, not your first.
Legitimate reasons to suppress:
- You intentionally want a “mount-only” effect and the dependency is truly stable in practice (e.g., a dispatch function from a context that you know will never change):
useEffect(() => {
dispatch({ type: 'INIT' });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);Third-party library returns an unstable reference that you cannot control and wrapping in
useCallback/useMemois not possible.You have verified with a ref that the value does not actually change and adding it would only add noise.
Before suppressing, run through this checklist:
- Can you move the function inside the effect?
- Can you stabilize the value with
useCallbackoruseMemo? - Can you depend on primitive values instead of objects?
- Can you use a ref for the non-reactive part?
If the answer to all four is no, add the disable comment with an explanation:
useEffect(() => {
// analytics.track is stable — the library guarantees it.
// Adding it to deps would require wrapping the entire provider.
analytics.track('page_view', { path: location.pathname });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.pathname]);Never suppress the warning just to make the linter shut up. That hides real bugs. If you are unsure whether suppressing is safe, it is not safe.
Fix 8: Restructure to Reduce Dependencies
Sometimes the real problem is that your effect does too much. An effect with five dependencies is hard to reason about and re-runs too often. Split it into smaller, focused effects:
// ❌ One big effect with many dependencies
useEffect(() => {
fetchUserData(userId);
setupWebSocket(userId, onMessage);
trackPageView(page);
}, [userId, onMessage, page]);
// ✅ Separate effects for separate concerns
useEffect(() => {
fetchUserData(userId);
}, [userId]);
useEffect(() => {
const ws = setupWebSocket(userId, onMessageRef.current);
return () => ws.close();
}, [userId]);
useEffect(() => {
trackPageView(page);
}, [page]);Each effect has a clear purpose and minimal dependencies. The WebSocket effect does not re-run when the page changes. The analytics effect does not re-run when the user changes.
This pattern also makes cleanup logic cleaner. Each effect returns its own cleanup function tied to its specific side effect.
The React Compiler and the future of dependencies
React Compiler (formerly React Forget) aims to automatically memoize values so you do not need useMemo and useCallback manually. If your project uses React 19+ with the compiler enabled, many of the referential equality issues disappear. However, the exhaustive-deps rule still matters — the compiler handles memoization, not correctness of your dependency arrays.
Common Patterns That Trigger the Warning
Pattern 1: Inline event handlers in effects
// ❌ Warning: missing dependency 'handleResize'
const handleResize = () => setWidth(window.innerWidth);
useEffect(() => {
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);Fix — move the handler inside:
// ✅ No warning
useEffect(() => {
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);Pattern 2: Depending on the previous state
// ❌ Warning: missing dependency 'count'
useEffect(() => {
const interval = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(interval);
}, []);Fix — use the functional updater form:
// ✅ No warning — count is not referenced
useEffect(() => {
const interval = setInterval(() => {
setCount((prev) => prev + 1);
}, 1000);
return () => clearInterval(interval);
}, []);The functional updater (prev) => prev + 1 removes the need to read count inside the effect. React passes the current value for you. This pattern is essential for intervals, timeouts, and any effect that updates state based on the previous value.
Pattern 3: Context values as dependencies
const { theme, locale } = useContext(AppContext);
// Warning if theme or locale is used but not in deps
useEffect(() => {
document.body.className = theme;
}, [theme]); // ✅ Include context values like any other dependencyContext values follow the same rules as props and state. If you use them inside an effect, they belong in the dependency array.
Debugging the Warning
When you are unsure which variable is causing the issue, check the warning message carefully. ESLint names the exact variable:
React Hook useEffect has a missing dependency: 'fetchData'.If the warning lists multiple dependencies, address each one individually. Do not blindly add them all — think about whether each value should actually trigger a re-run.
Use the React DevTools Profiler to see when your effects run. If an effect runs more often than expected after adding dependencies, one of those dependencies is changing on every render. Common culprits:
- Objects or arrays created inline during render
- Functions not wrapped in
useCallback - Derived values not memoized with
useMemo
If you run into ESLint parsing errors preventing the rule from running at all, fix those first. The exhaustive-deps rule cannot protect you if ESLint itself is not working.
Still Not Working?
If you have tried the fixes above and still see warnings or unexpected behavior:
Check that eslint-plugin-react-hooks is up to date. Older versions have known false positives. Run:
npm ls eslint-plugin-react-hooksUpdate to the latest version if you are behind:
npm install eslint-plugin-react-hooks@latest --save-devVerify your ESLint config includes the rule. The recommended config enables it automatically, but custom configs might not:
{
"plugins": ["react-hooks"],
"rules": {
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn"
}
}Check for circular dependency chains. If adding dependency A causes the effect to update B, which changes A, you have a loop. Break it by restructuring the component, using refs for the non-reactive parts, or combining related state into a single useReducer.
Make sure you are not calling Hooks conditionally. If your useEffect is inside an if block or after an early return, the rules of Hooks are violated and the linter cannot analyze dependencies correctly.
Look at custom hooks you are consuming. If a custom hook returns a new object or function on every render, the dependency issue is inside that hook. Either fix the hook to return stable references or memoize the values you consume from it.
Consider whether you need useEffect at all. Not every side effect belongs in useEffect. If you are deriving data from props or state, compute it during render instead. If you are responding to a user action, put the logic in the event handler. Overusing useEffect is a common source of both missing dependency warnings and unnecessary complexity.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: React Warning: Failed prop type
How to fix the React 'Warning: Failed prop type' error. Covers wrong prop types, missing required props, children type issues, shape and oneOf PropTypes, migrating to TypeScript, default props, and third-party component mismatches.
Fix: React TypeError: Cannot read property 'map' of undefined
How to fix React TypeError Cannot read property map of undefined caused by uninitialized state, async data loading, wrong API response structure, and missing default values.
Fix: React Cannot update a component while rendering a different component
How to fix React Cannot update a component while rendering a different component caused by setState during render, context updates in render, and Redux dispatch in render.
Fix: Invalid hook call. Hooks can only be called inside of the body of a function component
How to fix the React Invalid hook call error caused by mismatched React versions, duplicate React copies, calling hooks outside components, and class component usage.