useEffect Without the Footguns
- react
- useeffect
- hooks
- javascript
- typescript
- nextjs
- frontend
- webdev
In the last article, we looked at useState and useReducer Patterns and how to pick the right tool for local component state. Once state is modeled correctly, the next question is almost always: when do I need to reach outside the component to synchronize with something else? That's where useEffect comes in, and it's also where I've seen more production bugs than any other hook.
This is part of the React & Next.js Complete Guide series, where we build the mental models a senior engineer actually needs instead of just the API surface. useEffect has a small API and a large blast radius. Most footguns come from treating it like a generic "run this code" hook instead of what it actually is: a synchronization mechanism, which is the mental model I want to walk through first.
Effects are for synchronization, not for reacting to state
The name "effect" is a little misleading. It's tempting to read useEffect as "run this whenever state changes," which leads people to use it for things that have nothing to do with an external system: computing a derived value, formatting a name, updating one piece of state because another piece changed. An effect exists to synchronize your component with something outside of React's rendering model: a DOM subscription, a WebSocket connection, a document.title update, a manual chart library, a fetch request. If there's no external system involved, you probably don't need an effect, and I'll come back to that in a later section.
Once you frame it as synchronization, the dependency array stops being a mysterious lint rule and becomes what it says on the tin: the list of render values the synchronization depends on. Get that framing right and half the footguns disappear on their own.
The dependency array is not optional bookkeeping
The most common mistake I still see, including from experienced engineers who just haven't spent much time with hooks, is treating the dependency array as something to silence the linter rather than something that describes real behavior.
function SearchResults({ query }: { query: string }) {
const [results, setResults] = useState<Item[]>([]);
useEffect(() => {
fetchResults(query).then(setResults);
// missing `query` in the array below
}, []);
return <ResultsList items={results} />;
}
This effect captures query from the render it was created in and never sees a new value again, because the empty array tells React "never re-run this." The bug isn't obvious in development, since the first render looks correct. It shows up later when a user types a new search term and nothing happens.
The fix is almost never to suppress the lint warning. eslint-plugin-react-hooks's exhaustive-deps rule exists because the dependency array has exactly one correct answer for a given effect body: every reactive value it reads. Wanting to omit something is a signal the effect is doing too much. It's never a reason to lie to the linter.
useEffect(() => {
fetchResults(query).then(setResults);
}, [query]);
A subtler version shows up with objects and functions. If a parent passes a new object literal or inline callback on every render, and you put that object in your dependency array, the effect re-runs even though nothing meaningful changed:
function Chart({ config }: { config: { theme: string } }) {
useEffect(() => {
renderChart(config);
}, [config]); // new object identity every render from the parent
}
If config is created inline at the call site (<Chart config={{ theme: "dark" }} />), it's a new reference every render, and the effect re-synchronizes constantly. The fix is usually one of: memoize the object at the source with useMemo, depend on the primitive fields instead of the object (config.theme), or restructure so the parent doesn't recreate it. Depending on an unstable reference is a correctness problem dressed up as a performance one, and it shows up as a chart that flickers or a subscription that keeps tearing down and reconnecting.
Cleanup functions and the race condition you'll eventually hit
Every effect that starts something asynchronous needs to answer one question: what happens if the component unmounts, or the dependencies change, before that async work finishes? Skip it and you get a race condition that's invisible on a fast local network and shows up in production the moment a request is slow.
Here's the classic version of the bug:
function UserProfile({ userId }: { userId: string }) {
const [profile, setProfile] = useState<Profile | null>(null);
useEffect(() => {
fetchProfile(userId).then(setProfile);
}, [userId]);
return profile ? <ProfileCard profile={profile} /> : <Spinner />;
}
If userId changes quickly (a user clicking between two profiles), the first fetch can resolve after the second one, and you end up displaying stale data for the wrong user. The fix is to make the effect aware of its own lifecycle by returning a cleanup function that invalidates the in-flight request:
function UserProfile({ userId }: { userId: string }) {
const [profile, setProfile] = useState<Profile | null>(null);
useEffect(() => {
let ignore = false;
fetchProfile(userId).then((data) => {
if (!ignore) {
setProfile(data);
}
});
return () => {
ignore = true;
};
}, [userId]);
return profile ? <ProfileCard profile={profile} /> : <Spinner />;
}
For requests going through fetch directly, prefer an AbortController over a boolean flag, since it actually cancels the network request instead of just discarding the result:
useEffect(() => {
const controller = new AbortController();
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then((res) => res.json())
.then(setProfile)
.catch((err) => {
if (err.name !== "AbortError") {
setError(err);
}
});
return () => controller.abort();
}, [userId]);
This is exactly the shape of bug that ships silently and gets reported as "sometimes the wrong data flashes on screen for a second." It's rarely reproducible on a fast connection, which is precisely why it survives code review. A data-fetching library like React Query or SWR handles this entire class of problem, which is a strong argument for reaching for one once fetching in effects becomes a recurring pattern rather than a one-off.
You probably don't need an effect
Before writing an effect, ask whether the thing you're synchronizing is genuinely external to React. A surprising amount of "effect" code in real codebases is derived state or event handling in disguise, and both have simpler, more reliable alternatives.
Derived state doesn't need an effect at all:
// Unnecessary effect
function Cart({ items }: { items: Item[] }) {
const [total, setTotal] = useState(0);
useEffect(() => {
setTotal(items.reduce((sum, i) => sum + i.price, 0));
}, [items]);
return <TotalDisplay total={total} />;
}
// Just compute it during render
function Cart({ items }: { items: Item[] }) {
const total = items.reduce((sum, i) => sum + i.price, 0);
return <TotalDisplay total={total} />;
}
The effect version costs an extra render, introduces a window where total is stale relative to items, and adds a dependency to keep correct forever. If items is large enough that recomputing every render is a real cost, that's what useMemo is for, and only after you've measured the problem.
Things that happen in response to a user action, like showing a toast after a form submits, belong in the event handler. Watching for a "success" flag in an effect is the wrong tool for the job:
// Effect reacting to a flag: fragile and delayed
useEffect(() => {
if (submitted) {
showToast("Saved!");
}
}, [submitted]);
// Directly in the handler: immediate and obvious
async function handleSubmit() {
await saveForm(data);
showToast("Saved!");
}
The event handler version runs exactly once, exactly when you expect it to, and doesn't need a dependency array at all. The effect version runs on every render where submitted happens to be true, which includes re-renders you didn't cause, like a parent re-rendering for an unrelated reason.
StrictMode's double invocation isn't a bug in your code
If you're on React 18's StrictMode in development, you'll notice effects mount, unmount, and mount again on the initial render. This trips people up constantly, especially with a side effect that isn't idempotent, like an analytics call with no dedup logic. It's intentional: React does it to surface exactly the class of bug from the cleanup section above, since effects that don't clean up properly will misbehave under StrictMode, concurrent rendering, and fast refresh alike.
The right response is never to strip out StrictMode to make the symptom disappear. Treat the double mount as a stress test. If an effect can't survive being mounted, unmounted, and remounted immediately, it has a latent bug that would eventually surface in production anyway, under real behavior like fast navigation or a flaky connection triggering a remount.
Production pitfalls to watch for in code review
A few patterns are worth flagging specifically in pull requests that touch effects. An empty dependency array that references props or state from the closure almost always has a stale closure bug waiting to happen, even if it looks fine in the one test case someone tried. An effect that fetches data without cancellation logic is a race condition waiting to happen on a slow network. It's just a matter of when someone hits it. An effect that calls setState unconditionally inside a .then(), without checking whether the request is still current, will eventually cause updates for the wrong render. An object or array literal in a dependency array usually means the value should be memoized upstream, or the effect should depend on primitives instead. And multiple unrelated concerns living in one effect, like fetching data and also setting up a subscription, make the dependency array impossible to reason about; split them into separate effects that each synchronize one thing.
Key takeaways
- Treat `useEffect` as a synchronization tool for external systems (network, DOM, subscriptions), not a generic "run this when state changes" hook.
- The dependency array has one correct answer for a given effect body: every reactive value it reads. Silencing the linter usually hides a real bug.
- Object and function dependencies that get recreated every render cause effects to re-run constantly; fix the reference stability at the source rather than dropping the dependency.
- Every async effect needs an answer to "what if this resolves after the component moved on," via a cleanup flag or an `AbortController`.
- A lot of "effect" code is really derived state or event-handler logic; computing during render or handling directly in the event handler is simpler and more correct.
- StrictMode's double-invocation in development is a deliberate stress test for effect cleanup, not a bug to work around.
Frequently asked questions
Why does my useEffect run twice in development?
React 18's StrictMode intentionally mounts, unmounts, and remounts components on the first render to surface effects that don't clean up properly. It's a signal that your effect should be resilient to being started and stopped in quick succession, which is exactly what happens in production during fast navigation.
Should I add every value my effect uses to the dependency array?
Yes. Every reactive value the effect body reads (props, state, and anything derived from them) belongs in the array. If including a value causes the effect to re-run more often than makes sense, restructure the effect or memoize the value, rather than omit it.
How do I cancel a fetch request in useEffect?
Use an `AbortController`, pass its `signal` to `fetch`, and call `controller.abort()` in the effect's cleanup function. For non-fetch async work, a boolean "ignore" flag set in cleanup and checked before calling `setState` achieves the same goal, though it won't cancel the underlying request.
Is it okay to fetch data directly inside useEffect in a production app?
It works, but you own cancellation, race conditions, caching, and loading and error states. Once that pattern shows up more than once or twice in a codebase, a data-fetching library like React Query or SWR handles all of it and removes an entire category of bugs from every future effect.
When should I use useLayoutEffect instead of useEffect?
Only when you need to read layout from the DOM and apply a change before the browser paints, to avoid a visible flicker. It runs synchronously after DOM mutations and before paint, which makes it more expensive, so default to `useEffect` and reach for `useLayoutEffect` only when you can point to a specific flicker it fixes.
How do I know if I even need an effect?
Ask whether you're synchronizing with something outside React: a subscription, a manual DOM API, a network request, a timer. If not, and you're computing a value from existing state or props, or responding to a user action, that logic almost always belongs during render or directly inside an event handler instead.
Explore more on

About the author
I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.