React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

effectsside-effectslifecyclecleanuphooks

useEffect Hook

Synchronize a component with an external system by running side effects after render.

Recipe

Quick-reference recipe card — copy-paste ready.

// Run after every render
useEffect(() => {
  console.log("rendered");
});
 
// Run once on mount
useEffect(() => {
  fetchData();
}, []);
 
// Run when dependencies change
useEffect(() => {
  document.title = `You clicked ${count} times`;
}, [count]);
 
// Cleanup on unmount or before re-running
useEffect(() => {
  const id = setInterval(tick, 1000);
  return () => clearInterval(id);
}, []);

When to reach for this: You need to synchronize with something outside React — fetching data, subscribing to events, manipulating the DOM, or setting up timers.

Working Example

"use client";
 
import { useEffect, useState } from "react";
 
export function WindowSize() {
  const [size, setSize] = useState({ width: 0, height: 0 });
 
  useEffect(() => {
    function handleResize() {
      setSize({ width: window.innerWidth, height: window.innerHeight });
    }
 
    handleResize(); // Set initial size
    window.addEventListener("resize", handleResize);
 
    return () => window.removeEventListener("resize", handleResize);
  }, []);
 
  return (
    <p className="text-sm font-mono">
      Window: {size.width} × {size.height}
    </p>
  );
}

What this demonstrates:

  • Subscribing to a browser event (resize) inside useEffect
  • Returning a cleanup function to remove the listener on unmount
  • Empty dependency array [] so the effect runs only once on mount
  • Setting initial state inside the effect to avoid SSR hydration mismatch

Deep Dive

How It Works

  • useEffect runs after the browser paints the screen, not during render
  • React calls your setup function after the component mounts and after every re-render where dependencies changed
  • Before re-running the effect, React calls the previous cleanup function (if provided)
  • On unmount, React calls the cleanup function one final time
  • In Strict Mode during development, React mounts, unmounts, and re-mounts every component to surface missing cleanup logic

Parameters & Return Values

ParameterTypeDescription
setup() => void or () => () => voidEffect function; optionally returns a cleanup function
dependenciesunknown[] or omittedArray of reactive values the effect depends on
ReturnTypeDescription
(none)voiduseEffect does not return a value

Variations

Data fetching with cleanup (abort controller):

useEffect(() => {
  const controller = new AbortController();
 
  async function load() {
    const res = await fetch(`/api/users/${id}`, {
      signal: controller.signal,
    });
    const data = await res.json();
    setUser(data);
  }
 
  load();
  return () => controller.abort();
}, [id]);

Connecting to a WebSocket:

useEffect(() => {
  const ws = new WebSocket(url);
  ws.onmessage = (e) => setMessages((prev) => [...prev, JSON.parse(e.data)]);
  return () => ws.close();
}, [url]);

Syncing with localStorage:

useEffect(() => {
  localStorage.setItem("theme", theme);
}, [theme]);

TypeScript Notes

// Effect with async — must wrap because useEffect cannot return a Promise
useEffect(() => {
  async function fetchData() {
    const res = await fetch("/api/data");
    const json: DataType = await res.json();
    setData(json);
  }
  fetchData();
}, []);
 
// TypeScript catches missing cleanup return types automatically

Gotchas

  • Infinite loop — Updating state that is also a dependency causes the effect to re-run endlessly. Fix: Narrow your dependency array or move the state update behind a condition.

  • Missing dependencies — Omitting a value from the dependency array causes stale data inside the effect. Fix: Include all reactive values used inside the effect, or refactor to remove the dependency.

  • Async directly in useEffect — Returning a Promise instead of a cleanup function produces a React warning. Fix: Define an async function inside the effect and call it immediately.

  • Race conditions on fast re-renders — If id changes quickly, an older fetch may resolve after a newer one and overwrite correct data. Fix: Use an AbortController or a boolean ignore flag in cleanup.

  • Running on the serveruseEffect does not run during SSR. Accessing window or document without guarding causes build errors. Fix: Access browser APIs only inside useEffect or behind a typeof window !== "undefined" check.

Alternatives

AlternativeUse WhenDon't Use When
useLayoutEffectYou need to measure or mutate the DOM before the browser paintsThe effect does not touch layout or visual DOM
Event handlerSide effect is triggered by a specific user action (click, submit)Effect must run on mount or on value change
Server Component fetchData can be fetched at request time on the serverData depends on client-side state or user interaction
useSyncExternalStoreSubscribing to an external store (Redux, browser API)One-time setup or data fetching

Why not fetch in useEffect? For many apps, frameworks like Next.js offer server-side data fetching that avoids loading spinners and waterfalls. Reserve useEffect fetching for truly client-only data needs.

Real-World Example

From a production Next.js 15 / React 19 SaaS application (SystemsArchitect.io).

// Production example: Keyboard navigation for study cards
// File: src/hooks/use-card-keyboard.ts
import { useEffect } from "react";
 
interface UseCardKeyboardOptions {
  isAutoplayOn: boolean;
  cardNumber: number;
  totalCards: number;
  hasMoreCards: boolean;
  onCardClick: () => void;
  onNext: () => void;
  onPrev: () => void;
}
 
export function useCardKeyboard({
  isAutoplayOn, cardNumber, totalCards, hasMoreCards,
  onCardClick, onNext, onPrev,
}: UseCardKeyboardOptions) {
  useEffect(() => {
    const handleKeyDown = (event: KeyboardEvent) => {
      if (isAutoplayOn) return;
      if (event.code === "Space" || event.key === " ") {
        event.preventDefault();
        onCardClick();
      } else if (event.key === "ArrowLeft" || event.key === "a") {
        event.preventDefault();
        if (cardNumber > 1) onPrev();
      } else if (event.key === "ArrowRight" || event.key === "d") {
        event.preventDefault();
        if (cardNumber < totalCards || hasMoreCards) onNext();
      }
    };
    window.addEventListener("keydown", handleKeyDown);
    return () => window.removeEventListener("keydown", handleKeyDown);
  }, [cardNumber, totalCards, hasMoreCards, isAutoplayOn, onCardClick, onNext, onPrev]);
}

What this demonstrates in production:

  • Cleanup removes the keydown listener on unmount and whenever dependencies change, preventing stale handlers from accumulating.
  • Every referenced value inside the effect is included in the dependency array. Missing even one (like cardNumber) would cause stale closure bugs where old values are captured.
  • The onCardClick, onNext, and onPrev callbacks should be wrapped in useCallback by the parent component. Without that, every parent re-render creates new function references, causing this effect to unsubscribe and resubscribe the listener unnecessarily.
  • The isAutoplayOn guard at the top of the handler is a production pattern for disabling keyboard input during automated playback without removing the listener entirely.
  • Extracting this into a custom hook keeps the component clean and makes the keyboard behavior independently testable.

FAQs

What is the difference between an empty dependency array [] and no dependency array at all?
  • useEffect(() => { ... }, []) runs once on mount and cleans up on unmount.
  • useEffect(() => { ... }) runs after every single render.
  • Omitting the array is rarely intentional and often causes performance issues.
Why can't I pass an async function directly to useEffect?
  • useEffect expects the callback to return either undefined or a cleanup function.
  • An async function returns a Promise, which React cannot use as a cleanup function.
  • Wrap the async logic in an inner function and call it immediately:
useEffect(() => {
  async function fetchData() {
    const res = await fetch("/api/data");
    setData(await res.json());
  }
  fetchData();
}, []);
How do I prevent race conditions when fetching data in useEffect?
  • Use an AbortController to cancel the previous request when dependencies change.
  • Alternatively, use a boolean ignore flag set to true in the cleanup function.
useEffect(() => {
  const controller = new AbortController();
  fetch(`/api/users/${id}`, { signal: controller.signal })
    .then(res => res.json())
    .then(setUser);
  return () => controller.abort();
}, [id]);
Gotcha: Why does my useEffect run twice in development?
  • In React 18+ Strict Mode, React mounts, unmounts, and re-mounts every component during development.
  • This is intentional -- it surfaces missing cleanup logic (event listeners, subscriptions, timers).
  • It does not happen in production builds.
When should I use useEffect vs an event handler for side effects?
  • Use an event handler when the side effect is triggered by a specific user action (click, submit, keypress).
  • Use useEffect when you need to synchronize with an external system on mount or when a dependency changes.
  • Event handlers are simpler and avoid dependency-related bugs.
What does the cleanup function do and when does it run?
  • The cleanup function runs before the effect re-runs (when dependencies change) and once when the component unmounts.
  • It is used to unsubscribe from events, clear timers, or abort fetches.
  • If you forget cleanup, you risk memory leaks and stale handlers.
Gotcha: Why does adding an object as a dependency cause my effect to run on every render?
  • React compares dependencies with Object.is, which checks reference equality.
  • A new object literal {} or array [] created during render has a new reference every time.
  • Fix by memoizing the object with useMemo or extracting primitive dependencies.
How do I type the cleanup function in TypeScript?
useEffect((): (() => void) => {
  const id = setInterval(tick, 1000);
  return () => clearInterval(id);
}, []);
// TypeScript infers the cleanup type automatically
// in most cases -- explicit typing is rarely needed.
How do I access window or document safely inside useEffect with SSR?
  • useEffect only runs on the client, so window and document are always available inside it.
  • The danger is accessing them outside useEffect during server-side rendering.
  • Keep all browser API access inside useEffect or behind a typeof window !== "undefined" guard.
Should I split one large useEffect into multiple smaller ones?
  • Yes, if the effect handles unrelated concerns (e.g., fetching data and subscribing to resize events).
  • Each effect should have its own dependency array matching its specific concern.
  • This makes cleanup and dependency management simpler and less error-prone.
How does useEffect differ from useLayoutEffect?
  • useEffect runs after the browser paints the screen (asynchronous).
  • useLayoutEffect runs synchronously after DOM mutations but before the browser paints.
  • Use useLayoutEffect only when you need to measure or mutate the DOM before the user sees it (e.g., tooltips, animations).
  • useState — state that your effects read and write
  • useRef — mutable values that persist across renders without triggering effects
  • useCallback — stabilize functions used as effect dependencies
  • Custom Hooks — extract reusable effect logic into custom hooks