Topic #19Core17 min read

useEffect & Dependency Arrays

How useEffect synchronizes a component with the outside world after render, exactly what the dependency array does, why cleanup functions exist, and how stale closures and missing deps produce the most common React bugs.

#react#hooks#useeffect#dependency-array#cleanup#stale-closure#data-fetching#race-condition

What an effect is for (start here). useEffect is React's escape hatch for synchronizing your component with something outside React — a network request, a browser API, a subscription, a timer, a non-React widget. Rendering must be pure (no side effects), so React defers your effect until after it has committed the render to the DOM and the browser has painted. The mental frame is not 'run this on mount' but 'keep this external thing in sync with my current props/state.'

The signature and the three ways it runs. useEffect(setup, deps?) takes a setup function that may return a cleanup function, plus an optional dependency array. There are exactly three forms: with [] the effect runs once after the first render; with [a, b] it runs after the first render and again after any render where a or b changed; with no array at all it runs after every render — which is almost always a bug and a common cause of infinite loops when the effect also sets state.

How dependencies are compared. After each render React compares every entry in the deps array to its value from the previous render using Object.is (reference equality for objects/functions, value equality for primitives). If any entry differs, React first runs the previous effect's cleanup, then runs setup again. This is why an inline object or function in the deps array — {} or () => {} recreated every render — makes the effect re-run every time: a fresh reference is never Object.is-equal to the old one.

Cleanup is the other half of an effect. The function you return from setup is the cleanup: React runs it before re-running the effect (with new deps) and once more when the component unmounts. Its job is to undo what setup did — unsubscribe listeners, clearInterval/clearTimeout, close a WebSocket, abort an in-flight fetch, or flip a cancelled flag. Every subscription, timer, or listener you create in an effect must be torn down in cleanup, or you leak memory and stack up duplicate handlers.

The canonical data-fetching pattern. Fetch inside the effect, track loading/error/success in state, and guard against race conditions with a cancelled flag (or an AbortController). Without the guard, a fast filter change can fire request B, then request A resolves last and overwrites B's fresh data with stale results — a classic out-of-order bug. The cleanup sets cancelled = true so a late-resolving response is ignored. Note: in real apps prefer a data library (React Query, SWR) that handles caching, dedup, and races for you; the manual pattern is what those libraries encapsulate.

Stale closures — the deepest gotcha. An effect (like every function) is a closure: it captures the variables that were in scope when it was created. If a value the effect reads is missing from the deps array, the effect is not re-created when that value changes, so it keeps using the stale value it captured on an earlier render. A setInterval that reads count with [] deps will forever log the initial count of 0, because the interval callback closed over the first render's count. The fix is either to add the dependency or to use a functional state update that does not read the stale value.

Why exhaustive-deps exists and why you must not silence it. The react-hooks/exhaustive-deps lint rule statically finds every reactive value the effect reads and demands it be in the deps array. Silencing it with a comment is the number-one source of stale-closure bugs. When an effect genuinely should not re-run on some value, the correct fixes are: use the functional updater setX(prev => ...), move the value into a useRef, wrap a callback in useCallback / a value in useMemo so its identity is stable, extract a useReducer, or move the logic out of the effect entirely — never suppress the rule.

Effects run after paint; layout effects run before it. useEffect fires asynchronously after the browser paints, so it never blocks visual updates — right for most work. When you must read layout (measure a DOM node) and synchronously re-render before the user sees a flicker, use useLayoutEffect, which runs before paint. Reach for it only when a visible flash would otherwise occur; overusing it blocks painting and hurts performance.

Effects double-run in development (Strict Mode). In React 18+ Strict Mode, React intentionally mounts, unmounts, and remounts each component in development, so every effect runs setup → cleanup → setup once extra. This is a feature: it surfaces effects that are not idempotent or that forget cleanup. If your effect breaks under the double-invoke, it has a real bug (usually missing cleanup). Production runs the effect once.

Not everything belongs in an effect. A frequent anti-pattern is using an effect to transform props/state into more state ('when items change, setFilteredItems'). That derived value should just be computed during render (optionally memoized) — no effect needed. Effects are for external synchronization, not for reacting to your own state. Event-specific logic (what to do on a click) belongs in the event handler, not an effect that watches a flag.

The mental model (memorise this). An effect is a synchronization: 'given these dependencies, keep this external thing in sync, and here is how to clean it up.' The deps array is the list of values that, when changed, mean re-sync (cleanup then setup). Include every reactive value the effect reads (let exhaustive-deps enforce it), always clean up subscriptions/timers/requests, remember effects fire after paint and double-run in dev, and don't use an effect for data you could just compute during render.

Backend Analogy

useEffect is like a Spring bean's lifecycle glue: setup is @PostConstruct / a Vert.x verticle's start() where you open connections and register consumers, and the returned cleanup is @PreDestroy / stop() where you close them - forget it and you leak connections and stack up duplicate listeners, exactly like a leaked event-bus consumer. The dependency array is like a cache key or the parameters of a @Scheduled/reactive subscription: change the key and the subscription is torn down and re-established with the new inputs. A stale closure is the classic bug of capturing an old config object in a lambda instead of re-reading it, and the cancelled flag / AbortController is the same defensive pattern as cancelling a stale Future so a slow response can't overwrite a newer one.

Key Insights
  • An effect synchronizes a component with the outside world (fetch, subscription, timer, DOM API) and runs after React commits and the browser paints.
  • Three forms: [] runs once after mount; [deps] runs after mount and whenever a dep changes; no array runs after every render (usually a bug).
  • React compares deps with Object.is, so inline objects/functions get a fresh reference each render and make the effect re-run every time.
  • The returned cleanup runs before each re-run and on unmount - use it to unsubscribe, clear timers, abort requests, or flip a cancelled flag.
  • A stale closure is an effect using values it captured on an earlier render because those values are missing from the deps array.
  • Never disable exhaustive-deps; fix the root cause with functional updates, refs, useCallback/useMemo, useReducer, or restructuring.
  • Guard async fetches against race conditions with a cancelled flag or AbortController so a late response can't overwrite fresher data.
  • useEffect runs after paint; useLayoutEffect runs before paint - use the latter only to prevent a visible flicker when measuring layout.
  • React 18 Strict Mode double-invokes effects in development to surface missing cleanup and non-idempotent setup; production runs once.
  • Don't use effects to derive state from props/state - compute that during render; put click-specific logic in event handlers.

Worked Code

Canonical data fetching with race-condition guard
TSX
import { useState, useEffect } from 'react';

function Expenses({ userId }: { userId: string }) {
  const [items, setItems] = useState<Expense[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    let cancelled = false;                 // guards against out-of-order responses
    const controller = new AbortController();
    setLoading(true);
    setError(null);

    async function fetchData() {
      try {
        const res = await fetch('/api/expenses?user=' + userId, {
          signal: controller.signal,
        });
        if (!res.ok) throw new Error('HTTP ' + res.status);
        const data = await res.json();
        if (!cancelled) setItems(data);    // only the latest request wins
      } catch (err) {
        if (!cancelled && (err as Error).name !== 'AbortError') {
          setError((err as Error).message);
        }
      } finally {
        if (!cancelled) setLoading(false);
      }
    }
    fetchData();

    return () => {                         // cleanup: runs before re-run + on unmount
      cancelled = true;
      controller.abort();
    };
  }, [userId]);                            // re-fetch whenever userId changes

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error: {error}</p>;
  return <ExpenseList items={items} />;
}
Dependency array: the three forms
JSX
// []          -> runs ONCE after mount        (initial fetch, one-time setup)
useEffect(() => { setup(); }, []);

// [userId]    -> after mount + whenever userId changes (re-sync on param change)
useEffect(() => { refetch(userId); }, [userId]);

// (no array)  -> after EVERY render         (almost always a bug; can infinite-loop
//                                            if the effect also calls setState)
useEffect(() => { doThing(); });

// PITFALL: an inline object/function is a NEW reference every render, so this
// effect re-runs every render even though "options" looks constant:
const options = { limit: 10 };            // fresh object each render
useEffect(() => { load(options); }, [options]); // never Object.is-equal -> re-runs
// Fix: useMemo the object, hoist it out, or depend on primitive fields.
Stale closure bug and its two fixes
TSX
// BUG: interval captures count from the FIRST render (deps = []).
// It logs 0 forever because the callback closed over the initial count.
function BrokenCounter() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    const id = setInterval(() => console.log(count), 1000); // stale 'count'
    return () => clearInterval(id);
  }, []); // exhaustive-deps warns: missing 'count'
  return <button onClick={() => setCount(c => c + 1)}>+</button>;
}

// FIX A: use a functional update so you never read the stale value.
function FixedWithUpdater() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    const id = setInterval(() => setCount(c => c + 1), 1000); // always fresh
    return () => clearInterval(id);
  }, []); // no reactive value read -> honestly empty
  return <p>{count}</p>;
}

// FIX B: add the dependency so the effect re-subscribes with a fresh closure.
function FixedWithDep({ onTick }: { onTick: (n: number) => void }) {
  const [count, setCount] = useState(0);
  useEffect(() => {
    const id = setInterval(() => onTick(count), 1000);
    return () => clearInterval(id);
  }, [count, onTick]); // re-created whenever count/onTick change
  return <p>{count}</p>;
}
Subscription cleanup + anti-pattern (derived state)
TSX
// GOOD: subscribe in setup, unsubscribe in cleanup.
function useOnlineStatus() {
  const [online, setOnline] = useState(navigator.onLine);
  useEffect(() => {
    const on = () => setOnline(true);
    const off = () => setOnline(false);
    window.addEventListener('online', on);
    window.addEventListener('offline', off);
    return () => {                         // tear down BOTH listeners
      window.removeEventListener('online', on);
      window.removeEventListener('offline', off);
    };
  }, []);
  return online;
}

// ANTI-PATTERN: an effect that only derives state from props -> just compute it.
function List({ items, query }: { items: Item[]; query: string }) {
  // DON'T: const [filtered, setFiltered] = useState([]);
  //        useEffect(() => setFiltered(items.filter(...)), [items, query]);
  // DO: derive during render (memoize only if the filter is expensive).
  const filtered = items.filter(i => i.name.includes(query));
  return <ul>{filtered.map(i => <li key={i.id}>{i.name}</li>)}</ul>;
}

Try It Live

Edit the code and press Run — it executes safely in a sandboxed iframe. Use the Console tab for log output.

Stale closure vs functional update (plain JS timers)

Interview-Ready Q&A

It controls when the effect re-runs by comparing each dependency to its previous value with Object.is. An empty array [] runs the effect once after mount. An array like [userId] runs after mount and again whenever userId changes. Omitting the array entirely runs the effect after every render, which is almost always a bug and can cause an infinite loop if the effect also sets state.

Things to Remember
  • 1An effect keeps the component in sync with something external; it runs after paint.
  • 2[] = once after mount; [deps] = after mount + when a dep changes; no array = every render (bug).
  • 3React compares deps with Object.is, so inline objects/functions re-run the effect every render.
  • 4Always return cleanup for subscriptions, timers, sockets, and in-flight requests.
  • 5Missing deps cause stale closures; include every reactive value the effect reads.
  • 6Never silence exhaustive-deps - fix with functional updates, refs, useCallback/useMemo, or useReducer.
  • 7Guard async fetches with a cancelled flag or AbortController against races.
  • 8useLayoutEffect runs before paint; use it only to avoid a visible flicker.
  • 9Strict Mode double-invokes effects in dev to expose missing cleanup.
  • 10Derive state during render; don't use an effect to copy props/state into state.

References & Further Reading