useEffect Is Not a Lifecycle Hook (and Other Things I Wish I'd Learned Sooner)
Most useEffect bugs come from one mental model error: treating effects as lifecycle events. Here's how to spot infinite loops, stale closures, and effects that shouldn't exist.
I once spent the better part of an afternoon debugging a component that was hammering our API with the same request, over and over, several times per second. The network tab looked like a waterfall in a hurricane. The culprit was four lines long:
useEffect(() => {
fetchFilters().then(setFilters);
}, [options]);
options was an object literal created fresh on every render. New object, new reference, effect runs, setFilters triggers a render, which creates a new options, which... you get it. An infinite loop with a network request inside it.
But the interesting thing isn't the bug. It's the mental model that produced it. The author was thinking in lifecycles: "when the component mounts, or when options change, fetch the filters." That sentence sounds reasonable! And it's exactly the framing that gets people into trouble.
Effects synchronize; they don't "happen at" moments
The class-component era trained us to think in events: componentDidMount, componentDidUpdate, componentWillUnmount. Do X when Y happens. useEffect looks like it maps onto that model — empty deps array means "on mount," right? — but it genuinely doesn't, and the mismatch is where most effect bugs live.
The better mental model: an effect synchronizes something outside React with your component's current state. A subscription, a document title, a chart library, a WebSocket. The dependency array isn't "when to fire this event" — it's "these are the values this synchronization depends on." React's contract is that the effect will run as often as needed to keep things in sync, which under Strict Mode in development means it will run twice on mount, on purpose, specifically to flush out code that assumed "mount" was a one-time event.
If your effect breaks when it runs twice, it was never really an effect. It was an event handler wearing a costume.
The three failure modes I see most in review
1. The infinite loop with a disguise
The example above is the classic: an unstable dependency (object, array, or function created during render) makes the effect fire every render. Sometimes it's obvious. Often it's laundered through a custom hook — useSearchParams() or a config object built inline — so the instability is two files away from the effect. If a PR adds an effect whose dependency is anything other than a primitive or a properly memoized value, I trace where that value is created before I approve.
2. The stale closure
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1); // always uses the count from the first render
}, 1000);
return () => clearInterval(id);
}, []); // lint told you `count` was missing; someone silenced it
The counter goes 0 → 1 and stops. The interval callback closed over count from the render where the effect ran, and the empty deps array means it never re-runs. The fix here is the updater form, setCount(c => c + 1), but the smell is more general: an eslint-disable-next-line react-hooks/exhaustive-deps comment is almost always a stale closure waiting for its moment. When I see one in a diff, I don't assume the author outsmarted the linter. I assume the linter was right and ask what breaks when the missing dep is added — because that breakage is the real design problem.
3. The effect that shouldn't exist
This is by far the most common, and the most fixable:
// Before: state synced by effect
const [fullName, setFullName] = useState("");
useEffect(() => {
setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);
// After: just derive it
const fullName = `${firstName} ${lastName}`;
If a value can be computed from props or state during render, computing it in an effect adds an extra render cycle, a frame where the UI shows stale data, and a second source of truth that can drift. The same applies to "reset some state when a prop changes" (use a key on the component instead) and "do something when the user clicks" (that logic belongs in the click handler, not in an effect watching a clicked flag).
My rule of thumb in review: for every new useEffect, ask "what external thing is this synchronizing with?" If the answer is "nothing, it's transforming data" or "nothing, it's responding to a user action," the effect is probably a bug in waiting.
The AI assistant wrinkle
Here's something worth naming directly: AI coding assistants love useEffect. Trained on a decade of React code — much of it written during the lifecycle-thinking era — they'll cheerfully generate an effect to derive state, an effect to sync a prop into local state, an effect with a hand-wavy dependency array. The code looks idiomatic. It often works in the demo. And it carries exactly the bugs above.
I've reviewed multiple PRs where an assistant generated useEffect(() => { setX(transform(y)) }, [y]) — a pattern the React docs explicitly call out as unnecessary. The author didn't write it, so they didn't have a reason for it, so "why is this an effect?" got an honest shrug. That's fine! That's what review is for. But it means "this compiles and the feature works" is no longer strong evidence the effect is sound. Someone on the team has to hold the mental model, and increasingly that someone is the reviewer.
Don't forget the cleanup
One more habit worth building: every effect that starts something should stop it. Subscriptions, intervals, event listeners, in-flight fetches. The cleanup function isn't just for unmount — it runs before every re-synchronization too. Fetches deserve special care, because a slow response from an old request can land after a fast response from a new one and clobber your state:
useEffect(() => {
let cancelled = false;
fetchResults(query).then((data) => {
if (!cancelled) setResults(data);
});
return () => { cancelled = true; };
}, [query]);
(Or use an AbortController, or better yet, a data-fetching library that handles races for you — more on that in another post.)
What I actually check, in order
When a diff adds or touches an effect, here's my mental checklist:
- Does this need to be an effect at all? Derived data → compute in render. User events → event handler. Prop-driven reset →
key. - Are all dependencies stable? Trace every non-primitive dep to where it's created.
- Is the linter silenced? If so, there's a stale closure or a design problem underneath.
- Would it survive running twice? Strict Mode will make sure of it.
- Does it clean up after itself? Especially for anything async, where races hide.
Effects are a genuinely great tool for the thing they're for: keeping the outside world in sync with your UI. The trick is noticing how rarely that's actually the job at hand.