Premium Website Templates — Designer-Level UI for Modern Brands | ProofMatcher

React useEffect Hook: Complete Guide + Common Mistakes (2026)

useEffect in React 19: What's Changed

useEffect remains one of the most misunderstood React APIs in 2026, but the mental model has become clearer with React's official guidance and the patterns established by the ecosystem. React 19's compiler (React Forget, now stable) automatically manages many cases where developers previously needed to manually write useEffect with careful dependency arrays. Understanding when useEffect is still needed — and when it's an anti-pattern — is the key to writing correct React components.

The Dependency Array: The Source of Most Bugs

Every value used inside useEffect that can change over the component's lifetime must be in the dependency array. The React ESLint plugin (react-hooks/exhaustive-deps) enforces this rule and should be enabled in every React project. Violating the rule produces stale closure bugs — the effect reads an outdated value from a previous render. Never disable the exhaustive-deps rule for individual lines without understanding why it's warning; fix the underlying issue instead.

Common dependency array mistakes: including objects or arrays that are recreated on every render (causing infinite loops — use useMemo to stabilize them), omitting functions defined in the component body (use useCallback to stabilize them or define them inside the effect), and including values that should be read at event time not render time (use refs instead of state for values that shouldn't trigger re-renders).

The Cleanup Function

Effects that set up subscriptions, timers, or event listeners must return a cleanup function that tears them down. In React Strict Mode (development), effects run twice — setup, cleanup, setup — to surface bugs in effects that don't clean up correctly. If your effect breaks in Strict Mode, the cleanup function is wrong or missing. Common cleanup patterns: clearTimeout/clearInterval for timers, controller.abort() for fetch requests using AbortController, and unsubscribe() for event emitters and store subscriptions.

When NOT to Use useEffect

The React team's "You Might Not Need an Effect" documentation lists common anti-patterns: transforming data for rendering (use derived state or useMemo instead), handling user events (use event handlers instead), resetting state when props change (use the key prop instead), fetching data (use React Query, SWR, or the new use() hook with Suspense instead). In React 19, the use() hook with Suspense-compatible data sources is the recommended pattern for data fetching, eliminating the need for useEffect-based data fetching in most cases. Download React hooks pattern library at proofmatcher.com.

Think in Synchronisation, Not Lifecycle

A common source of confusion is thinking of effects as lifecycle methods such as "on mount" and "on update". A more accurate mental model is that each effect keeps one external system in sync with the current props and state. When its dependencies change, React stops the previous synchronisation by running the cleanup, then starts a new one with the latest values. Seen this way, the cleanup function is not an afterthought but the other half of the effect: connecting and disconnecting, subscribing and unsubscribing, starting and clearing a timer. If you cannot describe what an effect synchronises with in one short sentence, it is probably doing more than one job and should be split into separate effects, or it may not need to be an effect at all. Writing effects this way also makes their dependency arrays obvious, because the dependencies are simply the values that decide what you are synchronising with.

Fetching Data Without Race Conditions

When you do fetch in an effect, guard against responses arriving out of order. If a user switches quickly from profile A to profile B, the request for A might finish last and overwrite B's data. The fix is to ignore or cancel outdated requests in the cleanup function:

useEffect(() => {
  const controller = new AbortController();
  fetch(`/api/users/${userId}`, { signal: controller.signal })
    .then((res) => res.json())
    .then(setUser)
    .catch((err) => { if (err.name !== "AbortError") setError(err); });
  return () => controller.abort();
}, [userId]);

For most applications, a data-fetching library such as TanStack Query or SWR, or fetching in a framework's server components or loaders, handles caching, deduplication, and race conditions for you and is a better choice than hand-written fetch effects.

Why Effects Run Twice in Development

In development with Strict Mode, React mounts each component, unmounts it, and mounts it again. Effects therefore run, clean up, and run again. This is intentional: it exposes effects that are missing a cleanup function, such as subscriptions that are never removed or intervals that keep running. It does not happen in production. If an effect breaks when run twice, fix the cleanup rather than disabling Strict Mode.

useEffectEvent for Non-Reactive Logic

Sometimes an effect needs to read the latest value of something without re-running when it changes. For example, a chat connection effect should reconnect when the room changes, but not when the theme used for a notification changes. useEffectEvent, now a stable React API, lets you extract that logic into an effect event that always sees the latest props and state but is not a dependency. This removes the temptation to silence the exhaustive-deps lint rule, which is almost always a bug waiting to happen.

useLayoutEffect and useSyncExternalStore

useLayoutEffect runs after React updates the DOM but before the browser paints, so it is the right tool when you must measure an element and adjust layout without a visible flicker, such as positioning a tooltip. Because it blocks painting, use it only for that purpose. For subscribing to external data sources, such as browser online status, window size, or a third-party store, useSyncExternalStore is safer than an effect plus state, because it avoids tearing and works correctly with concurrent rendering and server rendering.

Custom Hooks Keep Effects Tidy

When the same effect logic appears in several components, move it into a custom hook such as useOnlineStatus or useWindowSize. The hook hides the effect, the cleanup, and the dependency details behind a clear name, and components simply use the returned value. Well-named custom hooks also make it obvious which external system each piece of code synchronises with.

The React Compiler and Effects

The React Compiler automatically memoises components, values, and functions, which removes most manual useMemo and useCallback calls and prevents many unnecessary re-renders. It does not remove the need to write effects correctly: dependency arrays, cleanup functions, and the question of whether an effect is needed at all are still your responsibility. Keep the lint rules enabled, and treat every effect as a small piece of synchronisation code with a clear start and a clear cleanup.

Common useEffect Mistakes at a Glance

  • Setting state that could be derived. If a value comes from props or other state, compute it during render instead of syncing it with an effect.
  • Missing dependencies. Leaving values out of the array causes stale data. Keep the exhaustive-deps lint rule on and fix the code rather than the warning.
  • Objects and functions as dependencies. An object or function created during render is new every time, so the effect runs on every render. Move it inside the effect, or outside the component if it does not depend on props.
  • No cleanup. Subscriptions, timers, and event listeners must be removed in the cleanup function, or they pile up and leak memory.
  • Async functions passed directly. useEffect(async () => ...) returns a promise instead of a cleanup function. Define an async function inside the effect and call it.
  • Chains of effects. Effects that set state which triggers other effects are hard to follow and cause extra renders. Combine the logic in the event handler that starts the change.