ESC

Type to search the knowledge base.

useEffect Fundamentals

When useEffect runs, how cleanup works, what belongs in the dependency array, and the bugs from treating it like componentDidMount.

intermediate5 min read
  • react
  • hooks
  • useEffect

useEffect is the escape hatch for synchronizing with something outside React: the network, the DOM, a third-party widget, window, a subscription. It is not “run this after render because I felt like it,” and it is not a second componentDidMount.

If your first instinct for any problem is useEffect, pause. Much “effect logic” is better as event handlers, derived render, or data libraries (React Query, router loaders). Effects exist for the remaining sync problems.

Primary docs: Synchronizing with Effects and useEffect API.

Where it sits in the render pipeline

  1. React renders your component (compute UI).
  2. React commits to the DOM.
  3. Browser may paint.
  4. Effects run after paint (passive effects).
  5. Before the next effect run for the same hook (deps change or unmount), React runs the cleanup from the previous run.
useEffect(() => {
  // setup: subscribe, start timer, attach listener
  return () => {
    // cleanup: unsubscribe, clear timer, detach
  };
}, [/* dependencies */]);

Effects do not run on the server during SSR. They only run in the client after hydration/commit. Don’t put SEO-critical data fetches exclusively in useEffect if you need them in the initial HTML.

The dependency array is a contract

Deps Behavior
Omitted (illegal in spirit; linter flags) Re-run after every commit — almost always wrong
[] Run once after mount; cleanup on unmount
[a, b] Re-run when a or b change by Object.is

React compares each dependency with Object.is (same as === for most values; NaN equals NaN).

// Re-fetch when userId changes; abort in-flight request on change/unmount
function UserBadge({ userId }) {
  const [user, setUser] = useState(null);

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

    async function load() {
      const res = await fetch(`/api/users/${userId}`, { signal: controller.signal });
      if (!res.ok) throw new Error('Failed');
      const data = await res.json();
      setUser(data);
    }

    load().catch((err) => {
      if (err.name === 'AbortError') return;
      console.error(err);
    });

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

  if (!user) return <p>Loading…</p>;
  return <p>{user.name}</p>;
}

Rule of thumb: if the effect reads a value that can change between renders, list it. The linter’s exhaustive-deps exists because missing deps are a top source of stale UI.

Stale closures (the classic bug)

// ❌ count inside the interval is forever the mount value if deps are []
useEffect(() => {
  const id = setInterval(() => {
    setCount(count + 1);
  }, 1000);
  return () => clearInterval(id);
}, []);

Fix with a functional update (effect doesn’t need latest count) or include count (restarts the interval every tick — usually worse):

useEffect(() => {
  const id = setInterval(() => {
    setCount((c) => c + 1);
  }, 1000);
  return () => clearInterval(id);
}, []);

Cleanup is not optional when you subscribe

Anything you start in setup that outlives a single effect run needs teardown:

  • addEventListener → removeEventListener
  • setInterval / setTimeout → clear*
  • WebSocket / EventSource → close
  • fetch → AbortController
  • Third-party chart instance → .destroy()

In React 18+ Strict Mode (development), React mounts → cleans up → mounts again on purpose to surface missing cleanup. If you “double-fetch” in dev, that’s often intentional; production won’t double-invoke the same way. Still write correct cleanup.

What should not be an effect

1. Derived state — compute during render:

// ❌
const [fullName, setFullName] = useState('');
useEffect(() => {
  setFullName(`${first} ${last}`);
}, [first, last]);

// ✅
const fullName = `${first} ${last}`;

2. User-event driven work — put it in the handler:

// ❌ submit triggers effect via flag
// ✅
function onSubmit(e) {
  e.preventDefault();
  postForm(formData);
}

3. Transforming data for rendering — useMemo only if measured expensive; usually just map in render.

4. Resetting state when a prop changes — prefer key={userId} on the component so React remounts a fresh tree, or adjust during render carefully (advanced pattern in docs).

Race conditions on async effects

useEffect(() => {
  let cancelled = false;

  (async () => {
    const data = await fetchItem(id);
    if (!cancelled) setItem(data);
  })();

  return () => {
    cancelled = true;
  };
}, [id]);

Without the flag (or abort), a slow response for id=1 can overwrite a fast response for id=2.

Effects vs layout effects

  • useEffect — after paint. Good default. Avoids blocking visual updates.
  • useLayoutEffect — after DOM update, before paint. Use when you must measure/mutate layout to avoid flicker (tooltip position, scroll restoration). Blocks paint; don’t abuse.

Interview angle

Q: “When does useEffect run relative to paint?”
A: After commit and (typically) after paint. Setup then runs; previous cleanup runs before the next setup when deps change.

Q: “Empty dependency array?”
A: Mount once + unmount cleanup. Still re-run setup/cleanup twice in Strict Mode dev.

Q: “How do you avoid stale props in an effect?”
A: Correct deps, functional setState, or refs for “latest value without re-subscribing” when you intentionally want a stable subscription.

Show a small fetch-with-abort or interval-with-functional-update. Name one thing that shouldn’t be an effect (derived state).

Further reading

Related guides