The Warning Developers Love to Ignore
You've seen it a hundred times:
React Hook useEffect has a missing dependency: 'someVariable'. Either include it or remove the dependency array. react-hooks/exhaustive-deps
The usual response? Slap an // eslint-disable-next-line above it and move on. Bad idea. This warning exists because missing dependencies cause real, subtle bugs โ the kind that only surface in production after a specific sequence of user interactions, and take hours to trace back to a stale closure.
A Concrete Bug Scenario
Here's a bug pattern I've seen in at least a dozen codebases:
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser(userId).then(data => setUser(data));
}, []); // โ ESLint warns: 'userId' is missing
}
Looks fine, right? Now imagine the parent passes a different userId โ say, when a user clicks to view another profile. The effect never re-runs. Old data stays on screen. The component silently shows information for the wrong person.
ESLint caught this. The empty array [] told React "run this once, never again" โ but the effect clearly depends on userId to do its job.
Why the Dependency Array Exists
React re-runs a useEffect whenever any value in its dependency array changes. Leave out a value the effect actually uses, and React never knows to re-run. The effect captures a stale snapshot of that variable and never updates โ a classic closure bug.
The react-hooks/exhaustive-deps ESLint rule statically analyzes your effect body and flags every variable that belongs in the array. It's annoying. It's also almost always right.
The Three Fixes
Fix 1: Add the Missing Dependency
Nine times out of ten, the answer is just: add it.
// Before (buggy)
useEffect(() => {
fetchUser(userId).then(data => setUser(data));
}, []);
// After (correct)
useEffect(() => {
fetchUser(userId).then(data => setUser(data));
}, [userId]);
Now whenever userId changes, the effect re-fetches fresh data. That's the behavior you actually want.
Fix 2: Functions as Dependencies โ Use useCallback
Here's where it gets messier. Functions defined inside a component body are recreated on every render. Add one to your dependency array without stabilizing it first, and you've got an infinite loop.
// Problem: fetchData is a new reference every render โ infinite loop
function SearchPage({ query }) {
const fetchData = () => fetch(`/api/search?q=${query}`);
useEffect(() => {
fetchData().then(/* ... */);
}, [fetchData]); // different fetchData every render!
}
Wrap the function in useCallback so it only changes when its own dependencies change:
function SearchPage({ query }) {
const fetchData = useCallback(() => {
return fetch(`/api/search?q=${query}`);
}, [query]); // stable reference; only updates when query does
useEffect(() => {
fetchData().then(/* ... */);
}, [fetchData]); // now safe
}
Fix 3: Move the Function Inside the Effect
If that function is only used by one effect, skip useCallback entirely and define it inside:
useEffect(() => {
const fetchData = () => fetch(`/api/search?q=${query}`);
fetchData().then(data => setResults(data));
}, [query]); // just query โ clean and obvious
Less indirection. Easier to read. Usually the right call.
When an Empty Array Is Actually Correct
Sometimes you genuinely want mount-only behavior โ setting up a WebSocket, initializing a third-party SDK, registering a global event listener. If the effect has no reactive dependencies, [] is correct:
useEffect(() => {
const socket = io('wss://example.com');
socket.on('message', handleMessage);
return () => socket.disconnect();
}, []); // correct: connect once, disconnect on unmount
The catch: if handleMessage reads state or props, you still have a stale closure problem. Use a ref to give the handler access to fresh values without re-creating the socket:
const handleMessageRef = useRef(handleMessage);
useEffect(() => {
handleMessageRef.current = handleMessage;
});
useEffect(() => {
const socket = io('wss://example.com');
socket.on('message', (msg) => handleMessageRef.current(msg));
return () => socket.disconnect();
}, []);
The Disable Comment โ Used Consciously, Not Lazily
eslint-disable isn't always wrong. It's wrong when used to silence a warning you haven't understood. Used deliberately, it's fine:
useEffect(() => {
// Intentional: log only the value at mount, not on every change
console.log('Initial count:', count);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
Always add a comment explaining why. Future you, reviewing this at 11pm during an incident, will appreciate it. Never disable the rule just to make the yellow squiggle disappear.
Verification Steps
- Check ESLint output: Save the file. The warning should vanish from your editor and from
npx eslint src/. - Test the dynamic case: If the effect depends on a prop like
userId, actually change that prop โ navigate to another user, switch a filter โ and confirm the effect re-runs with fresh data. - Watch for infinite loops: Added a function to the dependency array? Open the Network tab and check that requests aren't firing continuously. If they are, apply Fix 2 or Fix 3.
- Run the test suite: Effects that now re-run on prop changes sometimes expose tests that were quietly passing due to stale closures. Those tests were testing the wrong thing โ fix them.
Quick Reference
- Missing primitive (string, number, boolean) โ add it to the array
- Missing object or array โ verify it's stable (from
useState,useRef, oruseMemo); memoize if not - Missing function from component body โ move inside the effect, or wrap in
useCallback - Missing function from props โ wrap in
useCallbackat the call site, or move inside the effect - Genuinely mount-only effect โ empty array is correct; add an explanation comment if you disable the lint rule

