React useEffect Infinite Loop — Object Dependency Classic
Chrome’s Performance tab shows your component rendering 400 times a second. The Network tab is a vertical stripe of identical GETs. Someone “fixed” a stale closure by adding options to the useEffect dependency array. options is { pageSize: 20 } created inline in the parent. Every render builds a new object. React’s Object.is check fails. Effect runs. Effect sets state. Re-render. Loop.
This is the classic trap. The eslint rule react-hooks/exhaustive-deps is not wrong — your dependency identity is.
The minimal reproduction
function Parent() {
return <UserList options={{ pageSize: 20 }} />;
}
function UserList({ options }: { options: { pageSize: number } }) {
const [users, setUsers] = useState([]);
useEffect(() => {
let cancelled = false;
fetch(`/api/users?limit=${options.pageSize}`)
.then((r) => r.json())
.then((data) => {
if (!cancelled) setUsers(data);
});
return () => {
cancelled = true;
};
}, [options]); // new object every Parent render
return <ul>{users.map((u) => <li key={u.id}>{u.name}</li>)}</ul>;
}
Even if Parent itself is stable, any sibling state update re-renders Parent, recreates options, and re-fires the effect. Strict Mode double-mounting in development makes it look twice as bad — but production will still thrash if something upstream re-renders often.
Fix by stabilizing identity
Primitive deps when you can. Depend on options.pageSize, not the bag.
useEffect(() => {
// ...
}, [options.pageSize]);
Memoize at the call site when the object is real config:
const options = useMemo(() => ({ pageSize: 20, sort: "name" }), []);
return <UserList options={options} />;
Lift constants outside the component if they never change:
const DEFAULT_OPTIONS = { pageSize: 20 };
function Parent() {
return <UserList options={DEFAULT_OPTIONS} />;
}
Do not silence the lint rule with an empty array unless you truly want mount-only behavior and you understand stale props. Empty deps plus reading options inside is a different class of bug (stale data), not a fix for the loop.
Other identities that break equality
| Dependency | Why it churns | Stabilizer |
|---|---|---|
| Inline object / array | New reference each render | useMemo, hoist, or primitives |
| Inline function | New function each render | useCallback or move handler down |
| Context value object | Provider recreates value | Memoize provider value |
location from some routers | New object on each navigation tick | Depend on pathname / search strings |
// Bad: new function identity
useEffect(() => { onReady(); }, [onReady]);
// Better: parent wraps with useCallback, or effect depends on a stable id
If you need the latest callback without re-subscribing, keep a ref:
const onReadyRef = useRef(onReady);
useEffect(() => {
onReadyRef.current = onReady;
});
useEffect(() => {
const id = setInterval(() => onReadyRef.current(), 1000);
return () => clearInterval(id);
}, []); // subscribe once
How to confirm it is this bug
- Add
console.count("effect")inside the effect. If it climbs without user input, you have a loop. - Log the dependency with a
useRefprevious-value compare — print which key changed. - Temporarily replace the object dep with a JSON.stringify of its fields (debug only) — if the loop stops, identity was the issue.
- Check React DevTools “Highlight updates” — the parent flashing every frame usually owns the unstable prop.
setState patterns that amplify the fire
Setting state unconditionally inside an effect that depends on that same state (or on a derived object from it) is the other common fuse:
useEffect(() => {
setFilters({ ...filters, ready: true }); // filters in deps → loop
}, [filters]);
Prefer functional updates and narrower deps: setFilters((f) => ({ ...f, ready: true })) with [] or a boolean flag that flips once.
Stabilize what you put in the array. Measure with console.count before you reach for suppress comments. The infinite loop is almost always a new object wearing a familiar shape.