useEffect() in React: syncing with the outside world (timing, dependencies, cleanup)

LowIntermediateReact
Preparing for interviews?

Use guided tracks for structured prep, then practice company-specific question sets when you want targeted interview coverage.

Quick Answer

Explain what useEffect is actually for (synchronizing with external systems), when it runs (after commit), how dependencies control re-running, and how cleanup prevents leaks. Cover common patterns (fetching, subscriptions, timers) and common pitfalls (stale closures, infinite loops, derived state in effects).

Answer

Core idea

useEffect() is React’s API for side effects: code that must run outside rendering to keep your component synchronized with something external (network, subscriptions, browser APIs, timers, logging/analytics).

Key mental model: render computes UI (pure), then React commits DOM changes, then useEffect runs to “sync with the world.”

Concept

What it means

Why it matters

Runs after commit

Effects run after React has committed updates to the DOM (they don’t run during render).

You avoid doing real-world work in render (render may run multiple times).

Cleanup is part of the contract

An effect can return a cleanup function.

React runs cleanup before re-running the effect and on unmount (prevents leaks).

Dependencies define when to re-sync

The dependency array tells React when your effect’s inputs changed.

Correct deps prevent stale values and accidental infinite loops.

What useEffect is for (interview-grade mental model)

Dependency array rules (what actually happens)

React decides whether to re-run an effect by comparing dependency values with Object.is.

Dependency form

When it runs

Typical usage

[]

Runs on mount; cleanup runs on unmount.

Set up/tear down a subscription, event listener, timer.

[a, b]

Runs on mount and whenever a or b changes; cleanup runs before each re-run.

Re-sync when specific inputs change (e.g., userId, query, enabled).

No array

Runs after every commit; cleanup runs before each re-run.

Rare; usually a smell (often missing deps).

How dependencies control re-running + cleanup timing

Edge cases: objects, arrays, and refs in deps

React compares deps with Object.is. If you create a new object/array/function in render, it is a new identity each render, so the effect re-runs every time. Fix by memoizing (useMemo/useCallback) or moving the value outside render. Refs are different: the ref object from useRef is stable, so adding ref to deps usually does nothing. Changes to ref.current do not trigger re-renders, so do not expect deps to react to it; use state or a callback ref when you need to respond to DOM changes.

JSX
import React from 'react';

export default function DocumentTitle({ count }) {
  // Sync with a browser API (document title)
  React.useEffect(() => {
    document.title = `Count: ${count}`;
  }, [count]);

  return <div>Count: {count}</div>;
}
                  
JSX
import React from 'react';

export function ResizeLogger() {
  React.useEffect(() => {
    const onResize = () => console.log('width:', window.innerWidth);
    window.addEventListener('resize', onResize);

    // Cleanup prevents leaks + duplicate listeners
    return () => window.removeEventListener('resize', onResize);
  }, []);

  return <div>Resize the window</div>;
}
                  
JSX
import React from 'react';

export function IntervalCounter() {
  const [n, setN] = React.useState(0);

  React.useEffect(() => {
    const id = setInterval(() => {
      // Use functional update to avoid stale state
      setN((x) => x + 1);
    }, 1000);

    return () => clearInterval(id);
  }, []);

  return <div>{n}</div>;
}
                  

Data fetching pattern (avoid stale results)

Fetching is a side effect. The common issue is: the component re-renders with a new userId while an older request is still in flight. You need a cancellation/ignore strategy.

JSX
import React from 'react';

export function UserProfile({ userId }) {
  const [user, setUser] = React.useState(null);
  const [error, setError] = React.useState(null);

  React.useEffect(() => {
    const controller = new AbortController();

    (async () => {
      try {
        setError(null);
        const res = await fetch(`/api/users/${userId}`, { signal: controller.signal });
        if (!res.ok) throw new Error('Request failed');
        const data = await res.json();
        setUser(data);
      } catch (e) {
        if (controller.signal.aborted) return; // ignore abort
        setError(e);
      }
    })();

    return () => controller.abort();
  }, [userId]);

  if (error) return <div>Failed</div>;
  if (!user) return <div>Loading...</div>;
  return <div>{user.name}</div>;
}
                  

Common pitfalls (what interviewers watch for)

Pitfall

What goes wrong

Fix

Missing dependencies

Effect reads stale values/props because it captured an old closure.

Include every value used inside the effect in the dependency list, or restructure (move logic into effect, use refs for mutable non-render state).

Infinite loop

Effect sets state that changes a dependency, which re-runs the effect forever.

Don’t use effects to derive state from state; compute derived values during render or use memoization.

Async effect function directly

Writing useEffect(async () => { ... }) returns a Promise instead of cleanup.

Wrap async work inside the effect and return a real cleanup function.

Using useEffect for pure derivations

Extra renders + harder logic (state duplication).

If it can be computed from props/state, compute it in render (or useMemo if expensive).

Most real-world bugs come from deps, stale closures, or using effects as “state sync”

useEffect vs useLayoutEffect

useEffect is the default. Use useLayoutEffect only when you must read layout or synchronously write to the DOM before the browser paints (measurement, scroll positioning). Overusing useLayoutEffect can hurt performance because it can block paint.

StrictMode note (dev)

In development with <React.StrictMode>, React may run an effect’s setup + cleanup twice on mount to surface unsafe side effects. This is why effects must be idempotent and clean up correctly.

Summary
      • useEffect is for synchronizing a component with external systems (network, subscriptions, timers, browser APIs) after React commits UI.
      • The dependency array defines when React should re-run the effect; cleanup runs before re-run and on unmount.
      • Common bugs: missing deps (stale closures), effect-driven derived state (loops), and forgetting cleanup (leaks).
      • Default to useEffect; use useLayoutEffect only for layout-sensitive DOM work.
Similar questions
Guides
32 / 41