The problem with useEffect
useEffect is the hook everyone reaches for and the one that bites them most.
It looks innocent: run some code after render. But the same line that fetches
data or syncs a subscription is also the line that freezes a tab, double-fires a
request, or quietly loops until the page dies.
Almost every useEffect bug is the same bug wearing a different mask: the effect
runs more often than you think. Once you see that, the whole hook gets easier.
The render loop
Start with the worst version, because it explains the rest.
function Counter() {
const [count, setCount] = useState(0);
// ❌ no dependency array → runs after EVERY render
useEffect(() => {
setCount(count + 1);
});
return <div>{count}</div>;
}Here's the cycle. State changes trigger a re-render. useEffect runs after the
render. This effect sets state. Setting state triggers another render, which runs
the effect, which sets state again. React just keeps spinning until the tab
crashes.
The dependency array is what stops it. The second argument tells React when to re-run the effect. An empty array means "only once, on mount."
function Counter() {
const [count, setCount] = useState(0);
// ✅ empty array → runs once
useEffect(() => {
setCount(1);
}, []);
return <div>{count}</div>;
}Now the effect fires a single time and we're done.
The disguised loop
The obvious version is easy to catch. The sneaky one survives code review, because it has a dependency array and still loops.
function SearchResults({ query }) {
const [results, setResults] = useState([]);
// ❌ `filters` is a brand-new object on every render.
// { query, sort: "recent" } is a different object each time,
// so the effect sees a "new" dependency and re-runs forever.
const filters = { query, sort: "recent" };
useEffect(() => {
search(filters).then(setResults);
}, [filters]);
return <List items={results} />;
}React compares dependencies by reference, not by content. And objects, arrays, and functions get a brand-new reference on every render. So even when nothing changed, React sees a different object and runs the effect again. Step by step:
render #1 → filters = {…} (reference A)
→ effect runs → setResults → re-render
render #2 → filters = {…} (reference B, A !== B)
→ React: "dependency changed!" → effect runs again
render #3 → filters = {…} (reference C) → ...foreverIf that feels surprising, this is the whole reason why:
{ query: "react", sort: "recent" } === { query: "react", sort: "recent" }
// → falseSame content, different identity. React only checks identity.
Two ways to fix it
Stabilize the reference with useMemo so it only changes when the real data
changes:
const filters = useMemo(
() => ({ query, sort: "recent" }),
[query], // new object only when query actually changes
);Or skip the object entirely and depend on the primitive value, which React compares by content:
useEffect(() => {
search({ query, sort: "recent" }).then(setResults);
}, [query]); // ✅ primitive, compared by valueThe same trap applies to arrays ([1, 2] !== [1, 2]) and functions. For
functions, useCallback is the useMemo equivalent.
Catch it before it ships
You don't have to spot this by eye. Turn on the react-hooks/exhaustive-deps
ESLint rule and it flags missing and unstable dependencies right in your editor,
before they ever crash anything.
That's also exactly the class of bug React Doctor is built to find. Its
useEffect analyzer goes past missing deps and flags mutable dependencies,
mirrored props, missing cleanup, effect chains, and state set straight from an
event, then gives you a score and a fix for each one:
npx react-doctor@latest --verbose --diffThe real lesson
The dependency array isn't a formality you fill in to make the warning go away. It's the entire contract: it tells React when your effect is allowed to run. Get it wrong with a missing array and you loop. Get it wrong with an unstable reference and you loop in disguise.
So before you reach for useEffect, ask whether you need it at all. A lot of
effects are really just derived state, an event handler, or data fetching that a
framework already does better. The best fix for a useEffect bug is often one
less useEffect.