Skip to content

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.

A side effect is any work that reaches outside the render’s own return value:

  • Subscribing to a WebSocket, an event emitter, or window events
  • Setting timers (setInterval, setTimeout)
  • Reading or writing localStorage, the document.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.

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 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 mistake
useEffect(() => { console.log('rendered'); });
// Once on mount
useEffect(() => {
document.title = 'Dashboard';
}, []);
// Whenever userId changes
useEffect(() => {
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.

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.

useWindowWidth-ish
useEffect(() => {
function onResize() {
setWidth(window.innerWidth);
}
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
A timer
useEffect(() => {
const id = setInterval(() => setSeconds((s) => s + 1), 1000);
return () => clearInterval(id);
}, []);
A subscription that depends on a prop
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.

// Bug: the effect closes over the first render's `count` forever
useEffect(() => {
const id = setInterval(() => setCount(count + 1), 1000);
return () => clearInterval(id);
}, []); // count is missing
// Fix: don't read the value at all
useEffect(() => {
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.

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 → loops

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 constantly
const options = { userId, includeArchived: true };
useEffect(() => {
fetchThings(options);
}, [options]);

Fixes, best first:

// 1. Depend on the primitives instead
useEffect(() => {
fetchThings({ userId, includeArchived: true });
}, [userId]);
// 2. Move the object inside the effect
useEffect(() => {
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.

Most useEffect calls in real codebases should not exist. Two rules remove nearly all of them.

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.

// Wrong
useEffect(() => {
if (submitted) {
postOrder(cart);
setSubmitted(false);
}
}, [submitted, cart]);
// Right
function 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 disagree
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(`${first} ${last}`);
}, [first, last]);
// Right
const 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.

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.

UserProfile.jsx
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>;
}

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.

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.

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.

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.

  • 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-deps check 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 an ignore flag or AbortController — and real apps should use a query library or framework instead.