Effects and Data Fetching
Rendering must be pure: a component computes JSX and touches nothing else. useEffect is the escape hatch for everything that must happen outside React — synchronizing your component with a system React does not control.
What a side effect is
Section titled “What a side effect is”A side effect is any work that reaches outside the render’s own return value:
- Subscribing to a WebSocket, an event emitter, or
windowevents - Setting timers (
setInterval,setTimeout) - Reading or writing
localStorage, thedocument.title, cookies - Manually driving a non-React library (a chart, a map, a video player)
- Fetching data (with caveats — see below)
None of these belong in the component body, which may run more than once per screen update and must be repeatable without consequences.
The shape of useEffect
Section titled “The shape of useEffect”import { useEffect } from 'react';
useEffect(() => { // effect: runs after React commits changes to the DOM return () => { // cleanup: runs before the next effect run, and on unmount };}, [dependencies]);Effects run after the browser has painted the committed DOM, so they never block the visual update.
The dependency array
Section titled “The dependency array”The second argument controls when the effect re-runs. React compares each dependency to its previous value with Object.is; if any differ, it runs the cleanup, then the effect again.
| Second argument | When the effect runs |
|---|---|
| omitted | after every render |
[] |
once after the first render (and cleanup on unmount) |
[a, b] |
after the first render, and whenever a or b changes |
// Every render — almost always a mistakeuseEffect(() => { console.log('rendered'); });
// Once on mountuseEffect(() => { document.title = 'Dashboard';}, []);
// Whenever userId changesuseEffect(() => { console.log('Now showing user', userId);}, [userId]);The dependency list is not a configuration option — it is a claim that the effect only reads those values. Every reactive value the effect uses (props, state, and anything derived from them, including functions defined in the component) must be listed. Lying about it produces stale reads.
Values that never change do not need listing: useState setters, useReducer dispatch, and refs (ref.current is not reactive) are guaranteed stable by React. Module-level constants and imported functions are outside the component and are not dependencies either.
Cleanup, and why it exists
Section titled “Cleanup, and why it exists”The function you return undoes what the effect did. React calls it:
- before running the effect again (because a dependency changed), and
- when the component unmounts.
Without cleanup, every re-run stacks another subscription, timer, or listener — a leak that also causes duplicated handlers and “why did this fire twice?” bugs.
useEffect(() => { function onResize() { setWidth(window.innerWidth); } window.addEventListener('resize', onResize); return () => window.removeEventListener('resize', onResize);}, []);useEffect(() => { const id = setInterval(() => setSeconds((s) => s + 1), 1000); return () => clearInterval(id);}, []);useEffect(() => { const connection = createConnection(roomId); connection.connect(); return () => connection.disconnect();}, [roomId]);That last one is the model to internalize: when roomId changes from "general" to "random", React disconnects from general and connects to random. You never wrote “if the room changed, switch rooms” — you described what being connected means for a given roomId, and cleanup handles the transition.
Strict Mode double-invokes effects in development
Section titled “Strict Mode double-invokes effects in development”In development, <StrictMode> mounts each component, runs its effects, runs the cleanups, and runs the effects again. This is intentional: an effect that is not correctly cleaned up will visibly misbehave (two connections, two intervals, doubled fetches).
This does not happen in production builds. Do not “fix” it with a hasRun ref — fix the cleanup instead. If your effect survives being run twice in a row, it is correct.
Common mistakes
Section titled “Common mistakes”Missing dependencies
Section titled “Missing dependencies”// Bug: the effect closes over the first render's `count` foreveruseEffect(() => { const id = setInterval(() => setCount(count + 1), 1000); return () => clearInterval(id);}, []); // count is missing
// Fix: don't read the value at alluseEffect(() => { const id = setInterval(() => setCount((c) => c + 1), 1000); return () => clearInterval(id);}, []);This is a stale closure: the arrow function captured count from the render it was created in, and that snapshot never updates. The functional updater sidesteps it entirely.
Infinite loops
Section titled “Infinite loops”An effect that sets state which is also in its own dependencies re-triggers itself forever.
// Renders → effect → setState → renders → effect → ...useEffect(() => { setTotal(items.length);}, [items, total]);Two fixes, in order of preference: don’t use an effect (compute total during render), or remove the dependency that the effect itself changes.
The other loop source is a missing dependency array:
useEffect(() => { setX(compute()); }); // no array → runs every render → loopsObject and array dependencies
Section titled “Object and array dependencies”Dependencies are compared by identity. An object or array literal created during render is a new value every time, so the effect runs on every render.
// options is a new object each render → effect runs constantlyconst options = { userId, includeArchived: true };useEffect(() => { fetchThings(options);}, [options]);Fixes, best first:
// 1. Depend on the primitives insteaduseEffect(() => { fetchThings({ userId, includeArchived: true });}, [userId]);
// 2. Move the object inside the effectuseEffect(() => { const options = { userId, includeArchived: true }; fetchThings(options);}, [userId]);
// 3. Last resort: stabilize with useMemo (only if the object must be shared)const options = useMemo(() => ({ userId, includeArchived: true }), [userId]);The same applies to function dependencies — a function defined in the component body is a new identity every render unless wrapped in useCallback.
When you don’t need an effect
Section titled “When you don’t need an effect”Most useEffect calls in real codebases should not exist. Two rules remove nearly all of them.
Rule 1: user actions go in event handlers
Section titled “Rule 1: user actions go in event handlers”An effect that responds to “the user did something” is misplaced — it runs after a render instead of at the moment of the action, which makes ordering hard to follow.
// WronguseEffect(() => { if (submitted) { postOrder(cart); setSubmitted(false); }}, [submitted, cart]);
// Rightfunction handleSubmit() { postOrder(cart);}Analytics on a click, showing a toast, sending a mutation, navigating — all of that is handler work. Effects are for things caused by being displayed, not by being clicked.
Rule 2: derived values are computed during render
Section titled “Rule 2: derived values are computed during render”// Wrong: an extra render, and a window where the two disagreeconst [fullName, setFullName] = useState('');useEffect(() => { setFullName(`${first} ${last}`);}, [first, last]);
// Rightconst fullName = `${first} ${last}`;Other cases with non-effect answers:
| You want to… | Do this instead |
|---|---|
| Reset state when a prop changes | Give the component a key: <Form key={userId} /> |
| Filter or sort a list from props | Compute during render, useMemo only if measured slow |
| Cache an expensive calculation | useMemo |
| Share logic between components | A custom hook (which may or may not contain an effect) |
| Initialize something once per app | Do it at module scope, outside any component |
What legitimately stays: subscriptions to external systems, DOM measurement and imperative widget control, timers, browser API syncing, and analytics that fire on display.
Data fetching
Section titled “Data fetching”Fetching is a side effect, so useEffect can do it — but doing it correctly by hand means handling loading, errors, cleanup, and out-of-order responses.
The three-state pattern
Section titled “The three-state pattern”import { useEffect, useState } from 'react';
function UserProfile({ userId }) { const [user, setUser] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null);
useEffect(() => { let ignore = false; setIsLoading(true); setError(null);
fetch(`/api/users/${userId}`) .then((res) => { if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); }) .then((data) => { if (!ignore) setUser(data); }) .catch((err) => { if (!ignore) setError(err); }) .finally(() => { if (!ignore) setIsLoading(false); });
return () => { ignore = true; // ignore the result of a superseded request }; }, [userId]);
if (isLoading) return <p>Loading…</p>; if (error) return <p role="alert">Could not load user: {error.message}</p>; if (!user) return null;
return <h1>{user.name}</h1>;}Race conditions
Section titled “Race conditions”If userId changes from 1 to 2 quickly, two requests are in flight. There is no guarantee they finish in order — if 1’s response arrives last, it overwrites 2’s data and the UI shows the wrong user. This is not rare; it happens constantly with search-as-you-type.
The ignore flag above fixes it: when the effect re-runs, cleanup sets the previous run’s ignore to true, so its .then no longer writes state. The stale response is still received, just discarded.
AbortController
Section titled “AbortController”To actually cancel the request (saving bandwidth and server work), pass an AbortSignal:
useEffect(() => { const controller = new AbortController();
async function load() { try { const res = await fetch(`/api/users/${userId}`, { signal: controller.signal }); if (!res.ok) throw new Error(`HTTP ${res.status}`); setUser(await res.json()); } catch (err) { if (err.name !== 'AbortError') setError(err); // aborts are expected, not failures } finally { setIsLoading(false); } }
setIsLoading(true); load();
return () => controller.abort();}, [userId]);Aborting rejects the fetch promise with an AbortError, which you must filter out — otherwise every navigation shows an error. Note that finally still runs after an abort; if that is a problem, move the setIsLoading(false) into the success and error paths, or keep an ignore flag alongside.
You probably shouldn’t hand-roll this
Section titled “You probably shouldn’t hand-roll this”The pattern above is correct but incomplete. A production app also wants caching, deduplication of identical in-flight requests, background refetching, retries, pagination, and invalidation after mutations. Writing that yourself is a project.
Use one of:
- TanStack Query or SWR — client-side caching layers over your own fetch calls. The above component becomes a few lines.
- A framework’s data layer — Next.js Server Components and server actions, or React Router’s loaders, which fetch before rendering and avoid the request waterfall entirely.
React itself is moving in that direction: the use API and Suspense-based data reading are designed to be driven by frameworks and libraries rather than written by hand in effects. Learn the effect version so you understand what those tools do for you, then use the tools.
useEffect vs useLayoutEffect
Section titled “useEffect vs useLayoutEffect”useLayoutEffect has an identical signature but runs synchronously after DOM mutation and before the browser paints. Use it only when you must measure or adjust layout without a visible flicker — positioning a tooltip against a measured element, for instance. It blocks painting, so everything else belongs in useEffect.
Key points
Section titled “Key points”- Effects synchronize your component with systems outside React; rendering stays pure.
- The dependency array is a claim about what the effect reads. List every reactive value; let
react-hooks/exhaustive-depscheck you. []means “run once on mount”; no array means “run after every render” and is almost always wrong.- Return a cleanup function for anything ongoing. Strict Mode’s dev-only double run exists to expose missing cleanup.
- Stale closures come from missing deps — prefer functional state updates inside effects.
- Object/array/function dependencies change identity every render; depend on primitives or move them inside.
- No effect needed for event responses, derived values, or resetting state on a prop change (use
key). - Hand-written fetching must handle
res.ok, loading, errors, and race conditions via anignoreflag orAbortController— and real apps should use a query library or framework instead.