useEffect is the hook people reach for when they do not know where the code belongs. Data fetch, syncing to a document title, resetting state when a prop changes, subscribing to a socket, transforming data that could have been a variable. The dependency array turns orange in the linter. You copy a Stack Overflow snippet, add eslint-disable, and ship a loop.
React 18 made some of this louder with Strict Mode double-mounting in development. Effects that were “fine” now fetch twice, subscribe twice, or increment a counter twice. That is not React being cute. That is React showing you the effect is not idempotent.
This is how to decide when useEffect is the right tool, when it is fighting you, and how to replace the copy-paste patterns that cause stale closures, extra network calls, and state you cannot explain.
What useEffect is for
Effects synchronize React with something outside React: the network, the DOM, a subscription, a third-party widget. If there is no outside system, you probably do not need an effect.
Derive data during render. If fullName is first + ' ' + last, that is a variable, not an effect that setFullName. If you put that in an effect, you render once with the old name, then again with the new one. You also risk missing a dependency and showing a stale name.
If you are sending a request because the user clicked, that belongs in the click handler. An effect that watches shouldSubmit and posts when it becomes true is a state machine you invented because you were afraid to await in the handler. Handlers can be async. The loading flag can live in state. You do not need useEffect to talk to your own API on click.
The fetch-in-useEffect habit
The internet taught a generation to fetch on mount:
useEffect(() => {
fetch('/api/user').then(r => r.json()).then(setUser)
}, [])
Problems: no cancellation, no error state, Strict Mode double fetch, race if id is in the array and responses return out of order.
If id changes from 1 to 2, the request for 1 can finish last and overwrite user 2. The fix is an abort controller, or a let cancelled = false cleanup, or a library that handles this.
Should you fetch in an effect at all? For small apps, yes, with cleanup. For anything with caching, you want a library: TanStack Query, SWR, or a framework loader (Remix, Next, React Router). Those exist because the effect-plus-useState machine is a product.
If you keep the effect, at least:
- Abort on cleanup
- Handle loading and error
- Put
idin the dependency array and race-guard - Do not copy the response into five pieces of state you could keep as one object
setState in an effect to mirror props
useEffect(() => {
setValue(props.value)
}, [props.value])
This is how you get a local copy that drifts. Sometimes you need it for an input that you only want to reset when the record id changes, not on every keystroke from a parent. Then key the component: <Editor key={recordId} />. Local state remounts clean. No effect.
If you are mirroring props into state for no reason, delete the state. Use the prop. Extra state is extra bugs.
The object and array in the dependency list
useEffect(() => { ... }, [user]) where user is a new object every render will run every render. You meant user.id. Same for arrays built inline: useEffect(() => {}, [items.filter(...)]) is a new array every time.
The linter wants exhaustive deps because missing deps cause stale closures. The fix is not to disable the linter. The fix is to depend on primitives, or to memoize for a real reason, or to not use an effect.
Stale closures look like this: an effect with [] that reads count and always logs 0. The function closed over the first render. You wanted count in the array, or you wanted a ref if you truly needed “latest” inside a subscription without resubscribing.
Subscriptions without leaks
Sockets, resize listeners, setInterval: subscribe in the effect, unsubscribe in cleanup. Strict Mode will subscribe, clean up, subscribe again. If your server treats that as two users, fix the server or debounce the hello, but still implement cleanup. Production will unmount too.
Do not subscribe in the body of the component. That would subscribe every render.
If the socket needs the latest callback without reconnecting, keep the handler in a ref and update the ref each render. The effect depends on the socket instance, not on the changing callback.
You might want useMemo or useCallback, or nothing
useMemo is for expensive calculation or for referential stability you can measure. It is not a correctness tool for “I don’t want this to run.” If the calculation is cheap, compute it in render.
useCallback is for passing a stable function to a memoized child that actually checks function identity. If the child is not memoized, useCallback is noise. If you useCallback and then list changing deps, the function is not stable. You wrote a longer function.
Effects that exist only to setState from useMemo output are a circle. Compute, render, done.
Data fetching libraries and server components
If you are on Next.js App Router, server components can fetch without an effect. Client components still need a client-side story for interactivity. Do not fetch in an effect because a tutorial from 2019 did.
If you must fetch on the client, a query library gives you cache, retries, and no race if you use it as intended. Rolling your own is an article of faith, not a requirement.
A decision list you can keep next to the keyboard
- Transforming data from props or state? Do it in render.
- Responding to a click or submit? Do it in the handler.
- Resetting state when an id changes? Prefer
key. - Syncing to document title, analytics, or a non-React widget? Effect, with cleanup.
- Fetching? Prefer a framework or query library; if effect, abort and race-guard.
- Subscribing? Effect plus cleanup. Refs for latest handlers if needed.
When the linter complains, it is usually right. When you want to disable it, write a comment that would convince a skeptic, not “eslint-disable because loop.” If you have a loop, the effect is writing state that is also a dependency. That is a design bug. Lift the calculation out or rethink the state.
Strict Mode is a teacher
Double invoke in development is there so you notice missing cleanup and non-idempotent effects. Do not disable Strict Mode to hide a double POST. Make the POST happen in a handler, or make the effect safe to run twice (abort, unsubscribe, no increment without decrement).
If a third-party widget cannot be mounted twice, isolate it and live with the warning, or mount it in a layout that does not remount. Hiding Strict Mode is how you discover the leak in production.
The point
useEffect is a sync hook, not a lifecycle bucket. Most of the patterns copied from old class components (componentDidMount fetch, componentDidUpdate copy props) are the source of the pain. React got better at showing that pain.
Write effects for the outside world. Keep the rest in render and event handlers. When you need a fetch, use tools that already solved races. Artikals will keep coming back to this because it is the React bug that looks like “the framework is random” when it is really a missed dependency and a copied snippet. Delete the effect that only exists to set state from other state. The render was enough.