Skip to content

Chapter 2 of 7

useEffect and Side Effects: Patterns and Pitfalls

useEffect is the standard place to synchronize a component with an external system: subscriptions, timers, DOM mutations, analytics, and network requests. Three dependency-array shapes control its lifecycle. With [], the effect runs once on mount and its cleanup runs on unmount. With [dep], it re-runs whenever the dep changes, and cleanup runs before each re-run as well as on unmount. With no array at all, it runs after every render — almost never what you want. The cleanup function must reverse whatever the effect did: clear timers, remove listeners, abort fetches, disconnect observers. Forgetting cleanup is the single most common source of memory leaks and double-fire bugs, especially under StrictMode. Effects run in declaration order on mount; cleanup runs in reverse order on unmount, which is why later effects can rely on earlier ones still being alive.

Timing matters. useEffect runs asynchronously after the browser paints, so users may briefly see stale UI before the effect runs. When you need to read layout — for example, getBoundingClientRect — and apply changes synchronously to avoid flicker, use useLayoutEffect; it blocks the paint. Heavy work in useLayoutEffect delays rendering, so use it sparingly and only for measurement-and-update patterns. Effects do not participate in Suspense: useEffect cannot throw a Promise and be caught by a boundary, because by the time it runs the paint has already happened. For data that needs Suspense integration, use a data library, Server Components, or the use hook in render.

Two recurring footguns deserve special attention. First, the effect callback cannot be async, because returning a Promise breaks the contract that expects either a cleanup function or void. The fix is to define an inner async function and invoke it, or to use AbortController for cancellable work and call controller.abort() in cleanup. Second, an effect that updates state based on the same state it reads will loop unless guarded — track a request id in a ref and ignore stale responses, or set an exit condition in a ref. The same race-condition pattern shows up with two effects in flight writing to the same state: the slower one overwrites the newer result, so track a monotonically increasing id and discard responses whose id is no longer the latest. Whenever you find yourself reaching for useEffect, ask the "you might not need an effect" question first: derived values, event handlers, and expensive computations often belong outside it, with the effect reserved for genuine synchronization with an external system.

All chapters
  1. 1Foundations: Rules, StrictMode, and the Hook Mental Model
  2. 2useEffect and Side Effects: Patterns and Pitfalls
  3. 3Refs, Stale Closures, and Imperative Escapes
  4. 4Memoization and Render Performance
  5. 5State Management: useReducer, Context, and External Stores
  6. 6Custom Hooks: Reusable Patterns
  7. 7Concurrent Features, React 19 Hooks, and Server Architecture

Drill it

Reading is not remembering. These come from the React Hooks Advanced Patterns deck:

Q

Why does useEffect run twice in dev?

Strict Mode mounts → unmounts → remounts to catch bugs from missing cleanups. Production runs effects once per mount.

Q

Common cause of infinite useEffect loop?

Updating state inside the effect with a dep that changes when state updates. Memoize the value or remove from deps if it doesn't need to trigger.

Q

How does useCallback help?

Returns the same function reference unless deps change — avoids re-rendering children that take it as a prop and use memo().

Q

useMemo vs useCallback?

useMemo(f, d): memoizes a value (the result of f()).useCallback(f, d): memoizes the function itself. Equivalent to useMemo(() => f, d).