C.W.K.
Stream
Lesson 02 of 07 · published

useEffect — When You Actually Need It (And When You Don't)

~18 min · useeffect, synchronization, side-effects

Level 0React Novice
0 XP0/54 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
The biggest improvement you can make to a React codebase is removing useEffects that shouldn't be there. The React docs ship a whole page titled 'You Might Not Need an Effect.' Read it. Then re-read it.

What useEffect is actually for

useEffect synchronizes React state with something outside React: a DOM API (focus, scroll), a browser API (event listeners, timers, geolocation), a network connection (subscriptions, websockets), a third-party library that doesn't know about React. If the side effect is purely inside React (computing derived state, updating other state), you don't want an effect.

The dependency array

  • useEffect(fn) — runs after every render. Almost always wrong unless you're deliberately running on every render.
  • useEffect(fn, []) — runs once after mount (and on unmount if a cleanup is returned).
  • useEffect(fn, [a, b]) — runs after mount and whenever a or b changes by reference equality.

The ESLint rule react-hooks/exhaustive-deps flags missing dependencies. Don't suppress it casually — a missing dep is usually a bug.

The cleanup function

Return a function from the effect to clean up. React calls it before the effect re-runs and on unmount. This is how you remove event listeners, cancel subscriptions, abort fetches.

StrictMode double-invocation

In development, React's <StrictMode> intentionally mounts every component twice. Your mount-only effect runs, cleans up, runs again. This is a tool to surface effects that aren't safely idempotent — if your effect breaks under double-invoke, it has a bug (usually a missing cleanup, or a side effect that should not have been an effect in the first place). Production runs once.

Effects that shouldn't be effects

Five common anti-patterns the React docs explicitly call out:

  1. Transforming data for rendering → compute in render.
  2. Caching expensive computations → use useMemo (or trust the React Compiler in Track 7).
  3. Resetting state when a prop changes → use a key on the component.
  4. Adjusting state based on another state change → compute the dependent state during render.
  5. Notifying parent of a change → call the parent's callback from the event handler, not after the state settles.
If your effect's only job is to call setState, you usually don't need the effect. The setState belongs in the event handler or computation that caused the change. Effects are for synchronizing with the outside world, not for chaining React state updates.

Code

The canonical event-listener effect·tsx
import { useEffect, useState } from "react";

function WindowWidth() {
  const [width, setWidth] = useState(window.innerWidth);

  useEffect(() => {
    const onResize = () => setWidth(window.innerWidth);
    window.addEventListener("resize", onResize);

    // Cleanup — runs on unmount AND before the next time this effect re-runs.
    return () => window.removeEventListener("resize", onResize);
  }, []); // mount only

  return <p>Width: {width}px</p>;
}
Fetch with AbortController — race-safe·tsx
import { useEffect, useState } from "react";

function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState<{ name: string } | null>(null);

  useEffect(() => {
    const ctrl = new AbortController();
    fetch(`/api/users/${userId}`, { signal: ctrl.signal })
      .then((r) => r.json())
      .then(setUser)
      .catch((e) => {
        if (e.name !== "AbortError") throw e;
      });

    // If userId changes mid-fetch, the previous request is aborted —
    // no stale data lands in state.
    return () => ctrl.abort();
  }, [userId]);

  if (!user) return <p>Loading…</p>;
  return <p>{user.name}</p>;
}
An effect that shouldn't be an effect·tsx
// WRONG — chaining setState through an effect.
function Wrong({ firstName, lastName }: { firstName: string; lastName: string }) {
  const [fullName, setFullName] = useState("");
  useEffect(() => {
    setFullName(`${firstName} ${lastName}`); // triggers a re-render every time
  }, [firstName, lastName]);
  return <p>{fullName}</p>;
}

// RIGHT — derive in render.
function Right({ firstName, lastName }: { firstName: string; lastName: string }) {
  const fullName = `${firstName} ${lastName}`;
  return <p>{fullName}</p>;
}

External links

Exercise

Take one useEffect from your bootstrap project (or write one — a simple document.title updater is fine). First write it without cleanup; observe what happens when you navigate away. Then add cleanup. Then run with <StrictMode> and verify the double-invoke behavior. Then ask yourself: does this *need* to be an effect, or could it be a render-time computation?
Hint
useEffect(() => { document.title = Count: ${count}; }, [count]) is a real effect — it touches the document, which is outside React. Adding cleanup that restores the old title is the polished version. But computing Count: ${count} in render is just plain JSX — no effect needed if you render it inline.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.