Problem Solved

React · useEffect deps

Home › Courses › React › Problems › useEffect deps
⚛️ Problem · 5 min read

"useEffect has a missing dependency."
The four fixes, ranked by safety.

The exhaustive-deps rule warns about variables used inside useEffect that aren't in the dependency array. Add the dependency — sometimes it works, sometimes it causes an infinite loop. The rule isn't wrong; the fix depends on what's missing and why. This page covers the four correct fixes.

Short answer: The warning says an effect might use a stale value. Fix depending on what's missing: (1) props/state → add the dependency. (2) setters (setCount) → don't add — React guarantees they're stable. (3) functions → wrap in useCallback, then add. (4) objects/arrays → wrap in useMemo, or destructure what you need. (5) If the effect doesn't actually need the value, remove the dependency from the code inside. Use functional setState to avoid stale closures entirely.

✅ Solution 1 — Add the dependency (when it's truly needed)

For props and state used inside the effect.

If the effect reads a prop or state value that can change, that value must be in the dependency array. Otherwise the effect runs with the stale value. The fix is straightforward: add the dependency. This is the case the warning was designed for.

add-dep.tsx
import { useState, useEffect } from 'react';

interface User { id: string; name: string }

// ─── BAD: missing userId ─────────────────────
function BadUser({ userId }: { userId: string }) {
const [user, setUser] = useStatenull>(null);

useEffect(() => {
fetch(/api/users/${userId}`)
.then(r => r.json())
.then(setUser);
}, []); // ⚠️ missing userId

// Bug: when userId changes, effect doesn't re-run.
// The displayed user stays the old one until unmount.

return 

{user?.name ?? 'Loading...'}

; } // ─── GOOD: add userId to deps ──────────────── function GoodUser({ userId }: { userId: string }) { const [user, setUser] = useStatenull>(null); useEffect(() => { let cancelled = false; fetch(/api/users/${userId}`) .then(r => r.json()) .then(data => { if (!cancelled) setUser(data); }); return () => { cancelled = true; }; }, [userId]); // ✅ re-runs when userId changes return

{user?.name ?? 'Loading...'}

; } // ─── GOOD: add state that's read ───────────── function AutoSave({ value }: { value: string }) { const [draft, setDraft] = useState(value); useEffect(() => { const timer = setTimeout(() => { fetch('/api/save', { method: 'POST', body: draft }); }, 1000); return () => clearTimeout(timer); }, [draft]); // ✅ re-runs when draft changes return setDraft(e.target.value)} />; } console.log(BadUser, GoodUser, AutoSave);
import { useState, useEffect } from 'react';

function BadUser({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(/api/users/${userId}`)
.then(r => r.json())
.then(setUser);
}, []);
return React.createElement('p', null, user?.name ?? 'Loading...');
}

function GoodUser({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
let cancelled = false;
fetch(/api/users/${userId}`)
.then(r => r.json())
.then(data => { if (!cancelled) setUser(data); });
return () => { cancelled = true; };
}, [userId]);
return React.createElement('p', null, user?.name ?? 'Loading...');
}

function AutoSave({ value }) {
const [draft, setDraft] = useState(value);
useEffect(() => {
const timer = setTimeout(() => {
fetch('/api/save', { method: 'POST', body: draft });
}, 1000);
return () => clearTimeout(timer);
}, [draft]);
return React.createElement('input', { value: draft, onChange: e => setDraft(e.target.value) });
}

console.log(BadUser, GoodUser, AutoSave);

Props are reactive

If the effect reads a prop, add it. userId changing means the fetch should re-run. Add [userId].

State is reactive

If the effect reads state, add it. draft changing means the timer should reset. Add [draft].

Cleanup is crucial

Return a cleanup function — cancel timers, abort fetches, set a cancelled flag. Effects that depend on values rerun often; cleanup prevents leaks.

Not every warning is a loop

Adding a primitive dependency (userId, count) is safe. Adding an object or function is what causes loops — see next solutions.

✅ Solution 2 — Don't add functions/objects (wrap in useCallback/useMemo)

When the missing dependency is a function or object that changes every render.

If the missing dependency is a function or object defined in the component, its identity changes on every render. Adding it to the dependency array causes the effect to re-run every render — usually an infinite loop. The fix is to stabilize the identity: useCallback for functions, useMemo for objects/arrays.

stable-identity.tsx
import { useState, useEffect, useCallback, useMemo } from 'react';

// ─── BAD: function dep re-runs every render ──
function BadCallback({ id }: { id: string }) {
const [data, setData] = useState<unknown>(null);

function load() {
fetch(/api/${id}`).then(r => r.json()).then(setData);
}

useEffect(() => {
load();
}, [load]); // ⚠️ load changes every render → infinite loop

return 

{String(data)}

; } // ─── GOOD: useCallback stabilizes load ─────── function GoodCallback({ id }: { id: string }) { const [data, setData] = useState<unknown>(null); const load = useCallback(() => { fetch(/api/${id}`).then(r => r.json()).then(setData); }, [id]); // ✅ load only changes when id changes useEffect(() => { load(); }, [load]); // ✅ runs when load identity changes (i.e. when id changes) return

{String(data)}

; } // ─── BAD: object dep re-runs every render ──── function BadObjectDep() { const [count, setCount] = useState(0); const options = { threshold: 10 }; // new object every render useEffect(() => { console.log('count:', count, 'threshold:', options.threshold); }, [count, options]); // ⚠️ options changes every render return ; } // ─── GOOD: useMemo stabilizes the object ───── function GoodObjectDep() { const [count, setCount] = useState(0); const options = useMemo(() => ({ threshold: 10 }), []); // ✅ stable useEffect(() => { console.log('count:', count, 'threshold:', options.threshold); }, [count, options]); // ✅ runs when count changes return ; } // ─── GOOD: destructure what you use ────────── function DestructureFix({ config }: { config: { retry: boolean } }) { // Destructure primitive — no object identity issue const { retry } = config; useEffect(() => { console.log('retry:', retry); }, [retry]); // ✅ primitive dep, stable when value unchanged return null; } console.log(BadCallback, GoodCallback, BadObjectDep, GoodObjectDep, DestructureFix);
import { useState, useEffect, useCallback, useMemo } from 'react';

function BadCallback({ id }) {
const [data, setData] = useState(null);
function load() {
fetch(/api/${id}`).then(r => r.json()).then(setData);
}
useEffect(() => { load(); }, [load]);
return React.createElement('p', null, String(data));
}

function GoodCallback({ id }) {
const [data, setData] = useState(null);
const load = useCallback(() => {
fetch(/api/${id}`).then(r => r.json()).then(setData);
}, [id]);
useEffect(() => { load(); }, [load]);
return React.createElement('p', null, String(data));
}

function BadObjectDep() {
const [count, setCount] = useState(0);
const options = { threshold: 10 };
useEffect(() => { console.log('count:', count, 'threshold:', options.threshold); }, [count, options]);
return React.createElement('button', { onClick: () => setCount(c => c + 1) }, count);
}

function GoodObjectDep() {
const [count, setCount] = useState(0);
const options = useMemo(() => ({ threshold: 10 }), []);
useEffect(() => { console.log('count:', count, 'threshold:', options.threshold); }, [count, options]);
return React.createElement('button', { onClick: () => setCount(c => c + 1) }, count);
}

function DestructureFix({ config }) {
const { retry } = config;
useEffect(() => { console.log('retry:', retry); }, [retry]);
return null;
}

console.log(BadCallback, GoodCallback, BadObjectDep, GoodObjectDep, DestructureFix);

Functions need useCallback

A function defined in the component has a new identity every render. useCallback returns the same reference until deps change.

Objects need useMemo

Same for objects and arrays. New identity every render without memoization. useMemo returns the same reference until deps change.

Destructure to primitives

If you only use config.retry, destructure it: const { retry } = config. retry is a primitive — stable when the value doesn't change.

Setters are stable

setCount from useState is guaranteed stable. Don't add it to deps — you'll just trigger extra re-runs unnecessarily.

Before you reach for useCallback, check if the effect actually needs the function. If you're inlining a function into useEffect, just inline the function inside the effect. useEffect(() => { load(); }, [load]) becomes useEffect(() => { fetch(...).then(setData); }, [id]) — no function, no dependency, no warning. Move functions into the effect when they don't need to live outside it. This eliminates 80% of useCallback usage before you even write it.

✅ Solution 3 — Functional setState eliminates the dependency

The cleanest fix — remove the need for the dependency entirely.

If the effect's dependency is a state value that's only read to compute the next state, you can eliminate the dependency with a functional setter. setCount(count + 1) reads count from the closure; setCount(c => c + 1) receives the current value — no closure read, no dependency needed. This is the highest-value pattern in all of React hooks.

functional-setstate.tsx
import { useState, useEffect } from 'react';

// ─── BAD: reads count, needs dep, stale closure
function BadCounter() {
const [count, setCount] = useState(0);

useEffect(() => {
const id = setInterval(() => {
setCount(count + 1); // ⚠️ reads count from closure
}, 1000);
return () => clearInterval(id);
}, []); // ⚠️ missing count — stale closure

// Bug: count increments once, then stays at 1 forever.
// The closure captured count = 0 and never sees updates.

return 

{count}

; } // ─── GOOD: functional setter, no dependency ─── function GoodCounter() { const [count, setCount] = useState(0); useEffect(() => { const id = setInterval(() => { setCount(c => c + 1); // ✅ receives current value }, 1000); return () => clearInterval(id); }, []); // ✅ no count dep needed — no stale closure return

{count}

; } // ─── BAD: reads state to merge objects ─────── interface Form { name: string; email: string } function BadForm() { const [form, setForm] = useState
({ name: '', email: '' }); useEffect(() => { const draft = localStorage.getItem('draft'); if (draft) { const saved = JSON.parse(draft); setForm({ ...form, ...saved }); // ⚠️ reads form } }, []); // ⚠️ missing form — stale closure return
; } // ─── GOOD: functional update ───────────────── function GoodForm() { const [form, setForm] = useState({ name: '', email: '' }); useEffect(() => { const draft = localStorage.getItem('draft'); if (draft) { const saved = JSON.parse(draft); setForm(f => ({ ...f, ...saved })); // ✅ receives current form } }, []); // ✅ no form dep — safe return
; } // ─── GOOD: functional update for arrays ────── function ItemList() { const [items, setItems] = useState<string[]>([]); useEffect(() => { const id = setInterval(() => { setItems(prev => [...prev, item-${Date.now()}`]); }, 5000); return () => clearInterval(id); }, []); // ✅ no items dep — safe return

{items.length} items

; } console.log(BadCounter, GoodCounter, BadForm, GoodForm, ItemList);
import { useState, useEffect } from 'react';

function BadCounter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => { setCount(count + 1); }, 1000);
return () => clearInterval(id);
}, []);
return React.createElement('p', null, count);
}

function GoodCounter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => { setCount(c => c + 1); }, 1000);
return () => clearInterval(id);
}, []);
return React.createElement('p', null, count);
}

function BadForm() {
const [form, setForm] = useState({ name: '', email: '' });
useEffect(() => {
const draft = localStorage.getItem('draft');
if (draft) {
const saved = JSON.parse(draft);
setForm({ ...form, ...saved });
}
}, []);
return React.createElement('div', null);
}

function GoodForm() {
const [form, setForm] = useState({ name: '', email: '' });
useEffect(() => {
const draft = localStorage.getItem('draft');
if (draft) {
const saved = JSON.parse(draft);
setForm(f => ({ ...f, ...saved }));
}
}, []);
return React.createElement('div', null);
}

function ItemList() {
const [items, setItems] = useState([]);
useEffect(() => {
const id = setInterval(() => {
setItems(prev => [...prev, item-${Date.now()}`]);
}, 5000);
return () => clearInterval(id);
}, []);
return React.createElement('p', null, items.length, ' items');
}

console.log(BadCounter, GoodCounter, BadForm, GoodForm, ItemList);

setState(fn) receives current

Pass a function to setState: setCount(c => c + 1). c is always the latest value — no closure read, no stale value.

No dependency needed

If the effect only uses setCount (stable) and a functional update, there's no reactive dependency. Empty array is correct.

Works for any state shape

Objects: setForm(f => ({ ...f, ...patch })). Arrays: setItems(prev => [...prev, item]). Same pattern.

Use it everywhere

Not just in effects — in event handlers, in callbacks, everywhere. Functional setState is always safe when the new value depends on the old.

Empty dependency array is a promise, not a hack. [] tells React "this effect doesn't depend on any reactive values — never re-run it." If the effect actually reads props, state, or context, that promise is a lie. React will warn. The bug might be hidden now, but it'll surface when someone changes a prop and the effect doesn't react. Don't silence with // eslint-disable-next-line — fix the dependency or restructure the effect.

⚠️ Common mistakes

The dependency mistakes that cost real time.

1. Silencing with eslint-disable

// eslint-disable-next-line react-hooks/exhaustive-deps — hides the warning but keeps the bug. Fix: address the actual dependency.

2. Adding a function dep without useCallback

Triggers infinite loop. Fix: useCallback the function, or inline it inside the effect.

3. Adding an object dep without useMemo

Same infinite loop. Fix: useMemo the object, or destructure primitives from it.

4. Adding setState to deps

[count, setCount] — setCount is stable, so it's harmless but noise. Fix: only include what actually changes.

5. Reading state without needing to

setCount(count + 1) reads state. Fix: setCount(c => c + 1) — no read, no dep.

6. Missing cleanup

Effect with deps that change re-runs often. Without cleanup, timers/subscriptions leak. Fix: return () => cleanup().

7. Trying to run only on mount

Empty deps [] — but reads props. Fix: if truly mount-only, use a ref to capture the value. Better: restructure to depend on what it reads.

8. Multiple useEffects instead of one

Splitting into multiple effects with overlapping deps. Fix: one effect per concern, but respect the deps each needs.

❓ Frequently asked

What does "React Hook useEffect has a missing dependency" mean?

The exhaustive-deps ESLint rule scans the variables used inside your useEffect and compares them to the dependency array. If a variable used inside the effect isn't in the array, the rule warns that the effect might be using a stale value. The fix depends on intent: add the dependency and accept re-runs, wrap the value in useCallback/useMemo, use a ref, or restructure the effect.

Why can't I just add the missing dependency?

Adding it works sometimes but often creates an infinite loop. If the missing dependency is a function or object defined inside the component, its identity changes on every render — so the effect re-runs on every render, which triggers another render. The fix depends on what's actually missing.

What is a stale closure in useEffect?

A stale closure is when the effect captures a variable from an earlier render and uses that old value. If you write useEffect(() => { setCount(count + 1); }, []), the effect captures count = 0 forever. Fix: use a functional setter (setCount(c => c + 1)), or add the dep, or use a ref.

When should I use the functional setState inside an effect?

Use functional setState (setCount(c => c + 1)) whenever the new state depends on the old state inside an effect, an event handler, or a callback. It avoids stale closures entirely because you don't read count from the closure — you receive the current value as an argument.

🔗 Related

← All React problems
Problem solved · React