How does React handle side effects, and how do you manage them effectively?
Side effects (DOM mutation, network, subscriptions, timers, logging) live in useEffect — runs after commit, returns cleanup. Pure render = no side effects. Manage by: (1) keeping effects small and focused, (2) cleaning up properly, (3) avoiding state-from-state in effects (use derived render or key reset), (4) reaching for React Query for fetches, (5) using refs for non-reactive values. StrictMode dev double-mount surfaces missing cleanups.
React's side-effect model: render is pure, effects happen after commit.
What counts as a side effect
- DOM manipulation outside React (focus, scroll, third-party widgets).
- Network requests.
- Subscriptions (WebSocket, EventSource, observers).
- Timers (setTimeout, setInterval).
- Logging and analytics.
- localStorage / sessionStorage writes.
- Document title changes.
Reading state and producing JSX is NOT a side effect — that's render.
The useEffect contract
useEffect(() => {
// setup — runs after commit
return () => {
// cleanup — runs before next setup AND on unmount
};
}, [deps]);Rules:
- React calls setup after the DOM is committed (so the user sees the new UI before the effect runs).
- React calls cleanup before the next setup, and on unmount.
- React compares deps with Object.is. Same → skip the effect. Different → cleanup then setup.
Lifecycle in a function component
- Body executes → JSX returned.
- Reconciliation + commit.
- useLayoutEffect (synchronous, before paint).
- Browser paints.
- useEffect (asynchronous, after paint).
On next render: cleanups from previous run BEFORE new setups.
Examples
Subscription:
useEffect(() => {
const sub = stream.subscribe(handleData);
return () => sub.unsubscribe();
}, [stream]);Timer:
useEffect(() => {
const id = setInterval(tick, 1000);
return () => clearInterval(id);
}, []);DOM listener:
useEffect(() => {
const onResize = () => setW(window.innerWidth);
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);In-flight fetch:
useEffect(() => {
const ctrl = new AbortController();
fetch(url, { signal: ctrl.signal }).then(...);
return () => ctrl.abort();
}, [url]);Effective management
1. Keep effects small and focused. One concern per effect. Multiple effects in one component is fine — better than a mega-effect.
2. Don't sync state from state.
// BAD
useEffect(() => { setFullName(`${first} ${last}`); }, [first, last]);
// GOOD — derive in render
const fullName = `${first} ${last}`;3. Don't fetch in effects — use React Query.
Effects-for-fetching has well-known pitfalls (stale state, abort, race conditions, dedup). React Query handles all of them.
4. Reset state via key, not effect.
// BAD
useEffect(() => { setLocal(prop); }, [prop]);
// GOOD
<Form key={prop} initialValue={prop} />5. Use refs for non-reactive values.
const handlerRef = useRef(handler);
useEffect(() => { handlerRef.current = handler; }, [handler]);Lets you read the latest value inside an effect without re-running.
6. StrictMode catches bugs.
React 18 StrictMode dev mounts → unmounts → mounts each component. Cleanup that isn't idempotent will break.
Common bugs
- Missing cleanup: subscriptions/timers leak.
- Stale closure: setInterval captures the original state, not latest.
- Infinite loop: setState in effect with same value in deps.
- Object/array dep recreated each render: effect runs every render.
useEffect vs useLayoutEffect
| useEffect | useLayoutEffect | |
|---|---|---|
| Timing | After paint, async | After commit, before paint, sync |
| Use | Most cases | DOM measurement / synchronous DOM mutation |
| SSR | Doesn't run | Logs warning on server |
Default to useEffect; reach for useLayoutEffect only when paint-blocking is necessary.
Senior framing
useEffect is a contract: 'when these inputs change, perform this synchronization with the outside world, and clean up when done'. Most useEffect bugs are violations of that contract: missing cleanup, state-from-state syncs, missing deps. The React docs page 'You might not need an effect' is the canonical reference.
Follow-up questions
- •What's the difference between useEffect and useLayoutEffect?
- •Why is fetching in useEffect discouraged in favor of React Query?
- •How does StrictMode dev double-mount help catch effect bugs?
Common mistakes
- •Setting state from state inside useEffect.
- •Missing cleanup for subscriptions/timers.
- •Putting fetch logic directly in useEffect instead of React Query.
Performance considerations
- •Effects run after paint, so they don't block the user. But effects that synchronously trigger setState cause a second render — costly if frequent. Push derived state out of effects entirely where possible.
Edge cases
- •useEffect doesn't run during SSR.
- •useLayoutEffect logs a warning on server — wrap in useEffect or use isomorphic layout effect.
- •Effects from unmounted components don't run, but pending fetches still resolve — guard with AbortController.
Real-world examples
- •useEffect for: subscribing to WebSocket, syncing scroll to URL, attaching window listeners, logging analytics on route change, syncing to localStorage. Anti-patterns: setting state from props, fetching without a library.