Back to React
React
medium
mid

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.

8 min read·~5 min to think through

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

tsx
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

  1. Body executes → JSX returned.
  2. Reconciliation + commit.
  3. useLayoutEffect (synchronous, before paint).
  4. Browser paints.
  5. useEffect (asynchronous, after paint).

On next render: cleanups from previous run BEFORE new setups.

Examples

Subscription:

tsx
useEffect(() => {
  const sub = stream.subscribe(handleData);
  return () => sub.unsubscribe();
}, [stream]);

Timer:

tsx
useEffect(() => {
  const id = setInterval(tick, 1000);
  return () => clearInterval(id);
}, []);

DOM listener:

tsx
useEffect(() => {
  const onResize = () => setW(window.innerWidth);
  window.addEventListener('resize', onResize);
  return () => window.removeEventListener('resize', onResize);
}, []);

In-flight fetch:

tsx
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.

tsx
// 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.

tsx
// BAD
useEffect(() => { setLocal(prop); }, [prop]);

// GOOD
<Form key={prop} initialValue={prop} />

5. Use refs for non-reactive values.

tsx
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

useEffectuseLayoutEffect
TimingAfter paint, asyncAfter commit, before paint, sync
UseMost casesDOM measurement / synchronous DOM mutation
SSRDoesn't runLogs 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.

Senior engineer discussion

Senior signal: knowing the 'You might not need an effect' guidance. Most useEffect bugs disappear when the effect is deleted in favor of derived render or a proper data library.

Related questions