React Hook useEffect has a missing dependency: 'x'. Either include it or remove the dependency array. Most developers silence this with an eslint-disable comment. That works until it causes a stale-data bug that takes a day to find. The warning is usually correct, and there is a right fix for each situation.
๐ Table of Contents
- Why the Warning Exists
- Fix 1: Just Add the Dependency
- Fix 2: The Effect Loops After Adding It
- Fix 3: Function Dependencies โ useCallback
- Fix 4: You Need the Latest Value Without Re-Running
- Fix 5: Updating State From Its Own Previous Value
- When Disabling the Rule Is Actually Correct
- Do You Even Need an Effect?
- Decision Guide
- Frequently Asked Questions
- Conclusion
Why the Warning Exists
The dependency array tells React when to re-run an effect. If the effect reads a value that is not listed, the effect keeps the value from the render in which it last ran โ a stale closure. The code looks correct and behaves incorrectly.
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(r => r.json())
.then(setUser);
}, []); // โ ๏ธ missing dependency: 'userId'
return <div>{user?.name}</div>;
}
With an empty array the fetch runs once. Navigate from user 1 to user 2 and the component keeps showing user 1 forever. The warning predicted exactly this.
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(r => r.json())
.then(setUser);
}, [userId]); // โ
refetches whenever userId changes
Fix 1: Just Add the Dependency
In the majority of cases the warning is right and adding the dependency is the whole fix. Do this first and only investigate further if it causes a loop.
Fix 2: The Effect Loops After Adding It
Adding an object, array, or function to the dependency array often causes an infinite loop, because those are recreated on every render and compared by reference.
function Search({ filters }) {
const [results, setResults] = useState([]);
useEffect(() => {
search(filters).then(setResults);
}, [filters]); // new object identity every render -> infinite loop
}
Depend on the primitive values instead of the object.
useEffect(() => {
search({ query, category }).then(setResults);
}, [query, category]); // strings compare by value โ stable
When the object genuinely comes from a parent, memoise it there.
// Parent
const filters = useMemo(
() => ({ query, category }),
[query, category]
);
return <Search filters={filters} />;
Fix 3: Function Dependencies โ useCallback
A function declared in the component body is a new value every render, so listing it re-runs the effect endlessly.
function Dashboard({ userId }) {
// Recreated every render.
const loadData = async () => {
const res = await fetch(`/api/data/${userId}`);
return res.json();
};
useEffect(() => {
loadData().then(setData);
}, [loadData]); // loops
}
Two correct fixes. If the function is used only by the effect, move it inside โ the cleanest option, because the dependency disappears entirely.
useEffect(() => {
async function loadData() {
const res = await fetch(`/api/data/${userId}`);
setData(await res.json());
}
loadData();
}, [userId]); // โ
only the primitive is a dependency
If the function is shared with other parts of the component, wrap it in useCallback.
const loadData = useCallback(async () => {
const res = await fetch(`/api/data/${userId}`);
return res.json();
}, [userId]);
useEffect(() => {
loadData().then(setData);
}, [loadData]); // โ
identity only changes when userId changes
Fix 4: You Need the Latest Value Without Re-Running
Sometimes an effect legitimately should run once, but the callback inside needs current values. A ref holds a mutable value that does not participate in dependencies.
function Chat({ onMessage }) {
const onMessageRef = useRef(onMessage);
// Keep the ref current on every render.
useEffect(() => {
onMessageRef.current = onMessage;
});
useEffect(() => {
const socket = new WebSocket('wss://example.com');
socket.onmessage = (e) => onMessageRef.current(e.data);
return () => socket.close();
}, []); // โ
connects once, always calls the latest handler
}
This is the correct pattern for subscriptions, timers, and event listeners where reconnecting on every prop change would be wrong.
Fix 5: Updating State From Its Own Previous Value
Reading state to compute the next state makes that state a dependency, which usually causes a loop. The updater form removes the dependency.
// Loops: count changes -> effect re-runs -> count changes...
useEffect(() => {
const id = setInterval(() => setCount(count + 1), 1000);
return () => clearInterval(id);
}, [count]);
// โ
No dependency on count at all.
useEffect(() => {
const id = setInterval(() => setCount(c => c + 1), 1000);
return () => clearInterval(id);
}, []);
When Disabling the Rule Is Actually Correct
There is one legitimate case: a true mount-only effect where you deliberately want the initial value and nothing else. Analytics on first render is the standard example.
useEffect(() => {
analytics.track('page_view', { page: pageName });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Intentional: fire once with the initial pageName.
If you disable the rule, write a comment explaining why the omission is deliberate. A bare disable comment is indistinguishable from someone who did not understand the warning, including to yourself in six months.
Do You Even Need an Effect?
A large share of exhaustive-deps warnings come from effects that should not exist. Two common cases:
Derived state. Computing a value from props or state does not need an effect.
// Unnecessary effect, extra render, and a dependency warning.
const [fullName, setFullName] = useState('');
useEffect(() => { setFullName(`${first} ${last}`); }, [first, last]);
// โ
Just calculate it during render.
const fullName = `${first} ${last}`;
Responding to an event. Logic that belongs in an event handler often ends up in an effect watching state that the handler set.
// โ
Do the work where the event happens.
function handleSubmit() {
submitForm(data);
analytics.track('form_submitted');
}
Effects are for synchronising with systems outside React โ network, subscriptions, the DOM, timers. If nothing external is involved, an effect is probably the wrong tool.
Decision Guide
| Situation | Fix |
|---|---|
| Warning names a primitive | Add it to the array |
| Object or array causes a loop | Depend on its primitive fields, or useMemo at the source |
| Function causes a loop | Move it inside the effect, or useCallback |
| Need the latest value, must not re-run | Store it in a ref |
| State derives from previous state | Use the updater function form |
| Value is computed from props or state | Delete the effect and compute during render |
| Genuinely mount-only | Disable the rule with a written justification |
Frequently Asked Questions
Q: Why does my effect run twice in development?
A: Strict Mode intentionally mounts, unmounts, and remounts components to surface missing cleanup functions. It does not happen in production. If double-running breaks something, your effect is missing a cleanup.
Q: Can I just turn off the exhaustive-deps rule?
A: You can, and you will ship stale-closure bugs that are extremely hard to trace. The rule catches real defects; keep it on and fix the causes.
Q: Does useCallback everywhere hurt performance?
A: Slightly โ it costs memory and comparison work. Use it where identity stability actually matters, such as dependency arrays and memoised children, not reflexively on every function.
Q: What about the React Compiler?
A: It automates much of the memoisation that useCallback and useMemo do by hand, which removes a category of these warnings. It does not remove the need to understand which values your effect actually depends on.
Q: My effect needs a value but should not re-run when it changes. Is a ref a hack?
A: No, it is the documented pattern for that situation. Keeping the ref updated in a separate effect and reading .current inside the long-lived effect is idiomatic.
Conclusion
The exhaustive-deps warning is a bug detector, not a nuisance. Add the dependency first; if that loops, fix the identity problem with primitives, useMemo, or useCallback; use a ref when you need current values without re-running; use the state updater form to break self-referential loops; and delete the effect entirely when the value is merely derived. Reserve eslint-disable for genuine mount-only effects, and always leave a comment saying why.
๐ You might also like
๐ Share this article




โ๏ธ Leave a Comment