Chapter 6

useEffect: The Right Way to Handle Side Effects

What "Effect" Actually Means

A React component's contract is simple: given props and state, return a UI description. Everything outside that contract is a side effect — network requests, subscriptions, DOM mutations, localStorage reads, timers, analytics calls. These operations reach outside React's rendering model and interact with the external world.

useEffect exists for one specific purpose: synchronizing React state with external systems. The word "synchronize" is deliberate. Not "run code after render." Not "listen for changes." Synchronize — meaning keep two things in agreement over time.

When you think of useEffect as "run code after render," you start misusing it as a general-purpose post-render callback, and bugs follow. When you think of it as "keep the external world in sync with the current state," the behavior of every aspect of the API becomes coherent and predictable.

// Wrong mental model: "send a request when the component mounts"
useEffect(() => {
  fetchUser(userId);
}, []);

// Right mental model: "keep user data synchronized with userId"
useEffect(() => {
  fetchUser(userId).then(setUser);
}, [userId]); // re-sync whenever userId changes

The Dependency Array Contract

The second argument to useEffect(fn, deps) is not a trigger condition. It is a correctness contract: the dependency array must contain every reactive value from the component scope that the effect uses — props, state, context values, and derived computations from any of those.

This contract is enforced by the exhaustive-deps rule in eslint-plugin-react-hooks. Many developers treat these lint warnings as noise to be suppressed. That is dangerous — the lint rule exists because violating the contract produces real, subtle bugs.

Why Dependencies Must Be Exhaustive

function UserCard({ userId }) {
  const [user, setUser] = useState(null);

  // Bug: userId is missing from dependencies
  useEffect(() => {
    fetchUser(userId).then(setUser);
  }, []); // Only runs once, never re-syncs when userId changes

  // Correct: dependency list matches what the effect actually uses
  useEffect(() => {
    fetchUser(userId).then(setUser);
  }, [userId]);
}

The consequence of missing userId in the deps: the component mounts with userId = 1, fetches user 1, and correctly displays it. Then userId changes to 2. The effect never re-runs. The component keeps showing user 1's data while claiming it belongs to user 2. This is a stale dependency bug, and it can be incredibly hard to track down in production.

When the Dependency List Gets Long

A long dependency array is often a signal that the effect is doing too much. The solution is not to cheat on the deps — it is to split the effect or refactor the logic:

// One effect doing two unrelated things
useEffect(() => {
  document.title = `User: ${username}`;
  analytics.track('page_view', { page });
}, [username, page]);

// Better: separate effects for separate concerns
useEffect(() => {
  document.title = `User: ${username}`;
}, [username]);

useEffect(() => {
  analytics.track('page_view', { page });
}, [page]);

Each effect now has a clear, single responsibility, and its dependencies accurately reflect that responsibility.

The Cleanup Function: Effect Lifecycle

The function returned from useEffect is the cleanup function. React calls it at two points:

  1. Before running the next effect (when dependencies change)
  2. When the component unmounts

This design enables React to properly "tear down the old synchronization before establishing the new one":

useEffect(() => {
  // Establish sync: subscribe to this userId's data stream
  const subscription = dataStream.subscribe(userId, setData);

  return () => {
    // Tear down sync: unsubscribe
    subscription.unsubscribe();
  };
}, [userId]);

When userId changes from 1 to 2, React:

  1. Calls the cleanup from the previous effect (unsubscribes from user 1)
  2. Runs the new effect (subscribes to user 2)

This "cleanup then sync" pattern guarantees no dangling subscriptions or leaked resources, regardless of how many times dependencies change.

Correctly Cleaning Up Network Requests

Uncleaned fetch requests cause two categories of problems: updating state after the component has unmounted (the classic "Can't perform a React state update on an unmounted component" error), and race conditions where a slower earlier request overwrites the results of a faster later request.

// Flag-based cancellation (works without native abort support)
useEffect(() => {
  let cancelled = false;

  fetchUser(userId).then(data => {
    if (!cancelled) setUser(data);
  });

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

// AbortController: actually cancels the in-flight HTTP request
useEffect(() => {
  const controller = new AbortController();

  fetch(`/api/users/${userId}`, { signal: controller.signal })
    .then(r => {
      if (!r.ok) throw new Error(`HTTP ${r.status}`);
      return r.json();
    })
    .then(setUser)
    .catch(err => {
      if (err.name !== 'AbortError') setError(err);
    });

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

AbortController is strictly superior when available: it doesn't just ignore the stale response, it tells the browser to cancel the network request entirely, reducing server load and bandwidth consumption.

Strict Mode Double Invocation

React 18's <React.StrictMode> intentionally invokes effects twice in development:

  1. Mount → run effect
  2. Unmount → run cleanup
  3. Re-mount → run effect again

This is not a bug. It is a deliberate tool for finding effects that lack proper cleanup. If your effect behaves incorrectly when run twice in sequence — displaying duplicate toasts, opening multiple WebSocket connections, double-counting analytics events — your cleanup is incomplete.

// Problem: no cleanup, double invocation opens two connections
useEffect(() => {
  const ws = new WebSocket('wss://example.com');
  ws.onmessage = handleMessage;
}, []);

// Correct: cleanup closes the connection
useEffect(() => {
  const ws = new WebSocket('wss://example.com');
  ws.onmessage = handleMessage;
  return () => ws.close();
}, []);

Strict Mode only applies in development — production effects run once. But if your code fails the Strict Mode double-invocation test, it will likely fail in production scenarios too: Suspense can remount components, concurrent features can schedule and cancel effects, and future React versions may introduce additional cases where effects need to be resilient to multiple invocations.

Common Wrong Patterns

Pattern 1: Using Effects to Respond to Events

// Wrong: effect reacting to a state flag set by an event
useEffect(() => {
  if (submitted) {
    sendForm(formData);
    setSubmitted(false);
  }
}, [submitted, formData]);

// Correct: handle events directly in event handlers
function handleSubmit(e) {
  e.preventDefault();
  sendForm(formData);
}

Effects are for synchronization with external systems. User events are discrete, one-time actions that do not need synchronization semantics. When you route events through state and effects, you add unnecessary render cycles and make the data flow harder to reason about.

Pattern 2: Chained Effects That Derive State

// Wrong: effect to derive filtered from data
const [data, setData] = useState(null);
const [filtered, setFiltered] = useState([]);

useEffect(() => {
  if (data) setFiltered(data.filter(item => item.active));
}, [data]);

// Correct: compute derived values during render — no effect needed
const filtered = data?.filter(item => item.active) ?? [];

Every "update state from another state" effect adds an extra render cycle. If a value can be derived during render from existing state and props, derive it there. Effects that set state based on other state are almost always a code smell.

Pattern 3: Objects and Functions as Dependencies

// Parent renders this on every re-render with a fresh object literal
function Parent() {
  return <Child config={{ timeout: 3000 }} />;
}

function Child({ config }) {
  useEffect(() => {
    doSomething(config);
  }, [config]); // Re-runs on every parent render — config is always a new object
}

Object literals and function expressions create new references on every render. Even if the contents are identical, React sees them as different dependencies. Solutions:

// Option 1: destructure to primitive values as deps
useEffect(() => {
  doSomething(config.timeout);
}, [config.timeout]);

// Option 2: memoize the object in the parent
const config = useMemo(() => ({ timeout: 3000 }), []);

// Option 3: move the object outside the component if it's static
const CONFIG = { timeout: 3000 };
function Child() {
  useEffect(() => {
    doSomething(CONFIG);
  }, []); // CONFIG never changes, empty deps is correct
}

Effect Variants: useLayoutEffect and useInsertionEffect

React provides three effect hooks for different timing requirements.

useLayoutEffect

useLayoutEffect fires synchronously after DOM mutations but before the browser paints. Use it when you need to read DOM layout and immediately adjust the UI to avoid a visible flash:

function Tooltip({ content, targetRef }) {
  const [position, setPosition] = useState({ top: 0, left: 0 });
  const tooltipRef = useRef(null);

  useLayoutEffect(() => {
    // DOM is updated, browser hasn't painted yet
    const targetRect = targetRef.current.getBoundingClientRect();
    const tooltipRect = tooltipRef.current.getBoundingClientRect();
    setPosition({
      top: targetRect.bottom + 8,
      left: targetRect.left + (targetRect.width - tooltipRect.width) / 2,
    });
  }, [content]); // Recalculate whenever content changes

  return <div ref={tooltipRef} style={position}>{content}</div>;
}

If you used useEffect instead, the tooltip would first render at { top: 0, left: 0 }, the browser would paint it there for one frame, and then the effect would fire and move it — causing a visible flicker. useLayoutEffect prevents that by measuring and adjusting before the first paint.

The cost: useLayoutEffect is synchronous and blocks the browser from painting. Keep its work minimal. Also, useLayoutEffect emits a warning during server rendering because there is no DOM — if you need SSR compatibility, conditionally fall back to useEffect or accept the initial flash.

useInsertionEffect

useInsertionEffect fires before any DOM mutations, before useLayoutEffect, before useEffect. It exists specifically for CSS-in-JS libraries that need to inject style rules into the document before the layout is calculated. You will almost never use this directly — it is an API for library authors (styled-components, Emotion, etc.) to ensure their injected styles are present before any layout reads.

React 19: The use() Hook for Data Fetching

React 19 introduces the use() Hook, which can consume Promises and Context directly inside the render function, using Suspense as the loading boundary:

import { use, Suspense } from 'react';

// The Promise is created outside the component (in a cache, a parent, or a server component)
function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
  const user = use(userPromise); // Suspends until resolved
  return <h1>{user.name}</h1>;
}

function App() {
  // useState with lazy init ensures the Promise is created once
  const [userPromise] = useState(() => fetchUser(1));

  return (
    <Suspense fallback={<Skeleton />}>
      <UserProfile userPromise={userPromise} />
    </Suspense>
  );
}

use() does not replace useEffect for all purposes — it specifically targets the data-fetching use case that useEffect handles poorly. The React team's recommended architecture for data fetching in React 19 is:

  1. Server Components (Next.js App Router): fetch data directly in the component with async/await, no hooks needed
  2. Client Components with suspense: use use() with promises created in Server Components or a data cache
  3. Complex client-side state: React Query, SWR, or Zustand — libraries that handle caching, deduplication, and invalidation

Why useEffect Is a Poor Data Fetching Primitive

use() combined with Suspense and Error Boundaries addresses these problems structurally. The component simply declares what data it needs; the framework handles the when and how.

A Production-Grade Data Fetching Effect

Integrating everything in this chapter into a reusable custom hook:

type AsyncState<T> = {
  data: T | null;
  loading: boolean;
  error: Error | null;
};

function useAsyncData<T>(
  fetcher: (signal: AbortSignal) => Promise<T>,
  deps: React.DependencyList
): AsyncState<T> {
  const [state, setState] = useState<AsyncState<T>>({
    data: null,
    loading: true,
    error: null,
  });

  useEffect(() => {
    const controller = new AbortController();
    setState(prev => ({ ...prev, loading: true, error: null }));

    fetcher(controller.signal)
      .then(data => setState({ data, loading: false, error: null }))
      .catch(err => {
        if (err.name !== 'AbortError') {
          setState({ data: null, loading: false, error: err });
        }
      });

    return () => controller.abort();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, deps); // Caller controls deps

  return state;
}

// Usage
function UserCard({ userId }: { userId: string }) {
  const { data, loading, error } = useAsyncData(
    signal => fetch(`/api/users/${userId}`, { signal }).then(r => r.json()),
    [userId]
  );

  if (loading) return <Skeleton />;
  if (error) return <ErrorMessage error={error} />;
  return <div>{data?.name}</div>;
}

This hook correctly handles: dependency tracking, loading state transitions, error capture, request cancellation on dependency change, and cleanup on unmount.

Summary

useEffect is not a generic "run after render" mechanism — it is a tool for synchronizing external systems with React state. This distinction determines everything: which code belongs in an effect, which belongs in an event handler, and which should be computed during render.

The dependency array is a correctness contract, not a trigger list. The cleanup function's semantics are "tear down the previous synchronization," not "run on unmount." Strict Mode's double invocation is a development-time correctness check that surfaces cleanup bugs before they reach production.

As React 19's use() Hook and Server Components mature, the pattern of fetching data in useEffect will increasingly be replaced by framework-level data loading. Understanding when useEffect is the right tool — and when it's the wrong one — is a mark of genuine React fluency.

Rate this chapter
4.7  / 5  (56 ratings)

💬 Comments