React useEffect Hook

Handle side effects, manage dependencies, and clean up after effects.

TL;DR

  1. 01useEffect runs code after render for side effects like fetching data.
  2. 02Dependency array controls when the effect runs.
  3. 03Return a cleanup function to prevent memory leaks.

Tips

  1. 01Always include all dependencies in the dependency array — ESLint's exhaustive-deps rule helps catch missing ones.

Warnings

  1. 01Forgetting cleanup functions causes memory leaks — always clean up subscriptions, timers, and listeners.

Basic useEffect

  • Run a side effect after every render by calling useEffect with no dependency array.

    useEffect(() => {
      console.log("Component rendered"); // runs after every render
    });
    
  • Update the document title after each render as a common side-effect pattern.

    useEffect(() => {
      document.title = `You clicked ${count} times`;
      // Runs after every render where count may have changed
    });
    
  • Wrap async logic in an inner function because the effect callback itself cannot be async.

    useEffect(() => {
      async function loadData() {
        const res = await fetch("/api/users");
        const data = await res.json();
        setUsers(data);
      }
      loadData(); // call the async function immediately
    }, []);
    
  • Use useEffect for imperative side effects like logging, analytics, or third-party integrations.

    useEffect(() => {
      analytics.track("page_view", { page: pathname }); // fire-and-forget side effect
    }, [pathname]); // re-fires when the route changes
    
  • Keep effect callbacks synchronous at the top level and wrap any async work inside.

    // Wrong: async effect callback — cleanup return value is lost
    useEffect(async () => { await doSomething(); }, []);
    
    // Right: inner async function with explicit call
    useEffect(() => {
      async function run() { await doSomething(); }
      run();
    }, []);
    

Dependency Comparison Rules

  • Know that React compares each dependency to its previous value with Object.is, not a deep equality check — this is the exact mechanism that decides whether an effect re-runs.

    useEffect(() => {
      console.log("Count changed:", count);
      // Object.is(prevCount, count) === false triggers a re-run
    }, [count]);
    
  • Expect object and array literals to fail Object.is every render because a new reference is created each time, even when the contents are identical.

    useEffect(() => {
      fetchUserData(filters); // filters = { status: "active" } looks "changed" every render
    }, [filters]); // new object reference each render → effect re-fires constantly
    
  • Fix reference-instability by memoizing the object/array with useMemo or by destructuring to primitive dependencies instead.

    useEffect(() => {
      fetchUserData(status, token); // primitives compare correctly with Object.is
    }, [status, token]); // both must appear in the deps array
    
  • Omit the array entirely only when the effect must run after every single render — each item is still diffed with Object.is, there's just no array to skip the check.

    useEffect(() => {
      // Runs after EVERY render — use only when you truly need this
      syncStateToExternalSystem(value);
    }); // no array = every render
    
  • Enable the ESLint react-hooks/exhaustive-deps rule so it statically flags any variable read inside the effect that's missing from the array — it does not understand Object.is semantics, only static usage.

    # Install the rules-of-hooks plugin
    npm install eslint-plugin-react-hooks --save-dev
    # Add to .eslintrc: "react-hooks/exhaustive-deps": "warn"
    # Use the rule's quick-fix to auto-insert missing deps, then verify literals are memoized
    
  • Avoid silencing exhaustive-deps with an inline disable comment — it almost always hides a stale-closure bug rather than a false positive.

    useEffect(() => {
      fetchUserData(userId); // eslint-disable-next-line react-hooks/exhaustive-deps
      // Tempting but dangerous: userId changes won't re-trigger this effect
    }, []);
    

Data Fetching

  • Fetch data when the component mounts by using an empty dependency array.

    useEffect(() => {
      fetch("/api/data")
        .then((r) => r.json())
        .then(setData);
    }, []); // runs once on mount
    
  • Track loading and error state alongside the fetch for complete async UI handling.

    useEffect(() => {
      setLoading(true);
      fetch(url)
        .then((r) => r.json())
        .then(setData)
        .catch(setError)
        .finally(() => setLoading(false));
    }, [url]); // re-fetches when url changes
    
  • Cancel stale requests with AbortController to prevent setting state after unmount.

    useEffect(() => {
      const controller = new AbortController();
      fetch(url, { signal: controller.signal })
        .then((r) => r.json())
        .then(setData)
        .catch((err) => {
          if (err.name !== "AbortError") setError(err); // ignore intentional cancellation
        });
      return () => controller.abort(); // cancel on unmount or url change
    }, [url]);
    
  • Re-fetch automatically when a dependency like userId changes by including it in the array.

    useEffect(() => {
      async function load() {
        const res = await fetch(`/api/users/${userId}`);
        setUser(await res.json());
      }
      load();
    }, [userId]); // re-runs every time userId changes
    
  • Use SWR or React Query in production for caching, deduplication, and automatic revalidation.

    import useSWR from "swr";
    
    // SWR handles loading state, caching, and revalidation on focus
    const { data, error, isLoading } = useSWR("/api/user", fetcher);
    

Cleanup Functions

  • Return a function from the effect callback itself — this is the only way React knows it's a cleanup function, and it fires before unmount or before the next effect run.

    useEffect(() => {
      const subscription = subscribe((data) => {
        setData(data);
      });
      return () => subscription.unsubscribe(); // cleanup before unmount
    }, []);
    
  • Remember that the cleanup function closes over the props/state values from the render that scheduled it, not the latest render — it always sees the OLD values, never the current ones.

    useEffect(() => {
      const id = roomId; // captured by this closure
      connect(id);
      return () => disconnect(id); // disconnects the OLD roomId, even after roomId changes
    }, [roomId]);
    
  • Picture the exact ordering on every dependency change: React runs the PREVIOUS effect's cleanup first, fully, before invoking the NEW effect's setup — never interleaved.

    useEffect(() => {
      const timer = setInterval(() => setTime((t) => t + 1), 1000);
      return () => clearInterval(timer); // old timer fully cleared before a new one starts
    }, [intervalMs]); // change in intervalMs: cleanup(old) → setup(new), in that order
    
  • Pair every subscription-style API call with its exact inverse in cleanup — addEventListener/removeEventListener, connect/disconnect, open/close — so each effect run leaves zero residue for the next one to build on.

    useEffect(() => {
      const handleResize = () => setWidth(window.innerWidth);
      window.addEventListener("resize", handleResize);
      return () => window.removeEventListener("resize", handleResize); // exact inverse of setup
    }, []);
    
  • Treat a missing cleanup return as a silent bug, not a no-op — React still re-runs the new effect on every dependency change, it just never undoes the old one first.

    useEffect(() => {
      const ws = new WebSocket(`wss://example.com/${roomId}`);
      ws.onmessage = (e) => setMessages((prev) => [...prev, e.data]);
      return () => ws.close(); // without this, every roomId change leaks an open socket
    }, [roomId]);
    
  • Re-subscribe correctly when a dependency like userId changes by relying on cleanup to unsubscribe from the PREVIOUS value before the new effect subscribes to the current one.

    useEffect(() => {
      const sub = subscribe(userId); // subscribe to new user
      return () => sub.unsubscribe(); // unsubscribe from PREVIOUS user first
    }, [userId]); // runs cleanup on every userId change before re-subscribing
    

useEffect Patterns

  • Sync state with localStorage by reading on mount and writing when state changes.

    useEffect(() => {
      const saved = localStorage.getItem("theme");
      if (saved) setTheme(saved); // restore on mount
    }, []);
    
    useEffect(() => {
      localStorage.setItem("theme", theme); // persist on every change
    }, [theme]);
    
  • Track the previous value of a variable by storing it in a ref before each update.

    const prevCountRef = useRef<number | undefined>(undefined);
    
    useEffect(() => {
      prevCountRef.current = count; // store current as previous after each render
    }, [count]);
    
    const prevCount = prevCountRef.current; // previous value available during render
    
  • Use multiple separate useEffect calls for unrelated concerns to keep each effect focused.

    useEffect(() => { /* handle auth token refresh */ }, [token]);
    useEffect(() => { /* sync theme to body class */ }, [theme]);
    useEffect(() => { /* track window resize */ }, []);
    // Three concerns → three effects, each independently managed
    
  • Focus an input or dialog element after it becomes visible in the DOM.

    useEffect(() => {
      if (isOpen && inputRef.current) {
        inputRef.current.focus(); // focus runs after the DOM updates
      }
    }, [isOpen]); // re-runs whenever isOpen toggles to true
    
  • Update the document title reactively to reflect dynamic values like unread counts.

    useEffect(() => {
      document.title = unreadCount > 0
        ? `(${unreadCount}) My App`
        : "My App";
    }, [unreadCount]); // updates whenever unreadCount changes
    
  • Treat Strict Mode's extra dev-only mount cycle as a debugging aid, not a bug, when writing custom hooks or subscriptions.

    function useChatRoom(roomId) {
      useEffect(() => {
        const connection = createConnection(roomId);
        connection.connect();
        return () => connection.disconnect(); // must fully undo connect()
      }, [roomId]); // Strict Mode connects, disconnects, reconnects in dev to prove cleanup is correct
    }
    

FAQ