Skip to content

useEffect and Cleanup Patterns

intermediate13 min read

useEffect Is Not componentDidMount

Let's kill the most damaging mental model right now: thinking of useEffect(() => {}, []) as componentDidMount. It's not. useEffect is a synchronization mechanism. It synchronizes your component with an external system. The dependency array tells React when to re-synchronize.

// Mental model: "run this on mount" — WRONG
useEffect(() => {
  fetchUser(userId);
}, []);
// If userId changes, this effect does not re-run.
// The component is out of sync.

// Mental model: "synchronize with userId" — CORRECT
useEffect(() => {
  fetchUser(userId);
}, [userId]);
// When userId changes, effect re-runs. Always in sync.
Mental Model

Think of useEffect as a live connection to an external system. The setup function creates the connection. The cleanup function tears it down. The dependency array specifies what the connection depends on. When a dependency changes, React tears down the old connection and creates a new one. This is not "run once on mount" — it is "keep this component synchronized."

The Effect Lifecycle

Every effect goes through this cycle:

1. Component renders with current props/state
2. React commits to DOM and browser paints
3. React runs the effect's setup function
4. (Later) Component re-renders with new props/state
5. React runs the PREVIOUS effect's cleanup function
6. React runs the NEW effect's setup function
7. (On unmount) React runs the final cleanup function
function ChatRoom({ roomId }) {
  useEffect(() => {
    console.log(`Setup: connect to ${roomId}`);
    const connection = createConnection(roomId);
    connection.connect();

    return () => {
      console.log(`Cleanup: disconnect from ${roomId}`);
      connection.disconnect();
    };
  }, [roomId]);
}

// Switching from roomId='general' to roomId='react':
// 1. "Setup: connect to general"     (mount)
// 2. "Cleanup: disconnect from general"  (before new effect)
// 3. "Setup: connect to react"       (new effect)
Cleanup runs BEFORE the new effect, not after unmount

A common misconception: cleanup only runs on unmount. In reality, cleanup runs before EVERY re-execution of the effect. When roomId changes from 'general' to 'react', the old cleanup disconnects from 'general' BEFORE the new setup connects to 'react'. This prevents resource leaks — you never have two connections open simultaneously. The final cleanup on unmount is just the last one.

Dependency Array Deep Dive

This is where most useEffect bugs are born. Every value from the component scope that your effect uses must be in the deps array:

function SearchResults({ query, sortBy }) {
  const [results, setResults] = useState([]);

  useEffect(() => {
    // This effect reads query AND sortBy
    fetchResults(query, sortBy).then(setResults);
  }, [query, sortBy]); // Both must be listed
}

How React compares dependencies:

React compares each dependency with its value from the previous render using Object.is:

useEffect(() => {
  // Runs when ANY dep changes by Object.is comparison
}, [a, b, c]);

// Object.is('hello', 'hello') → true → no re-run
// Object.is(42, 42) → true → no re-run
// Object.is({}, {}) → false → RE-RUN (new reference!)

The Object/Array Dependency Trap

function Search({ query }) {
  // NEW object on every render!
  const options = { query, limit: 10 };

  useEffect(() => {
    fetchResults(options);
  }, [options]); // Object.is fails every render → infinite loop
}

Fixes:

// Fix 1: Depend on primitives, construct object inside effect
useEffect(() => {
  fetchResults({ query, limit: 10 });
}, [query]);

// Fix 2: useMemo the object
const options = useMemo(() => ({ query, limit: 10 }), [query]);
useEffect(() => {
  fetchResults(options);
}, [options]);

Let's look at every way you can accidentally create an infinite loop. You'll recognize at least one of these.

Loop 1: Missing Deps Array

useEffect(() => {
  setCount(count + 1); // Triggers re-render → effect runs → setState → re-render → ...
}); // No deps array = runs after EVERY render

Loop 2: Object in Deps

useEffect(() => {
  fetchData(config);
}, [{ endpoint: '/api', limit: 10 }]); // New object literal every render

Loop 3: setState Without Condition

useEffect(() => {
  const data = transform(rawData);
  setTransformed(data); // Triggers re-render → effect runs → setState → ...
}, [rawData]); // If rawData is a new reference each render → infinite

// Fix: compute during render, not in effect
const transformed = useMemo(() => transform(rawData), [rawData]);

Loop 4: Function in Deps

function Component({ onFetch }) {
  useEffect(() => {
    onFetch(); // If onFetch is recreated each render → infinite
  }, [onFetch]); // Parent must memoize onFetch with useCallback
}
Common Trap

The most insidious infinite loop: setting state in an effect that depends on a prop that is a new object/function reference each render. The parent re-renders, passes a new reference, the effect re-runs, sets state, the parent re-renders again. The fix is almost always at the parent level — memoize the prop — not in the child.

Race Condition Prevention

Now for the pattern you'll use in basically every production app. The standard approach for cancelling stale async operations:

useEffect(() => {
  let ignore = false;

  async function fetchData() {
    const response = await fetch(`/api/users/${userId}`);
    const data = await response.json();

    if (!ignore) {
      setUser(data);
    }
  }

  fetchData();

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

With AbortController for true cancellation:

useEffect(() => {
  const controller = new AbortController();

  async function fetchData() {
    try {
      const response = await fetch(`/api/users/${userId}`, {
        signal: controller.signal,
      });
      const data = await response.json();
      setUser(data);
    } catch (err) {
      if (err.name !== 'AbortError') {
        setError(err);
      }
    }
  }

  fetchData();

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

Production Scenario: Debounced Search with Cleanup

function Search() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);

  useEffect(() => {
    if (!query.trim()) {
      setResults([]);
      return;
    }

    const controller = new AbortController();
    const timeoutId = setTimeout(async () => {
      try {
        const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
          signal: controller.signal,
        });
        const data = await res.json();
        setResults(data);
      } catch (err) {
        if (err.name !== 'AbortError') {
          console.error('Search failed:', err);
        }
      }
    }, 300);

    return () => {
      clearTimeout(timeoutId);
      controller.abort();
    };
  }, [query]);

  return (
    <div>
      <input value={query} onChange={e => setQuery(e.target.value)} />
      <ResultsList results={results} />
    </div>
  );
}

This cleanup prevents: (1) debounced fetch for stale query, (2) in-flight request for old query overwriting new results.

Execution Trace
Type 'r'
query = 'r', setTimeout scheduled
300ms timer starts
Type 'e' (50ms later)
query = 're'
Cleanup: clearTimeout + abort old. New timer starts
Type 'a' (50ms later)
query = 'rea'
Cleanup again. No fetch has fired yet
300ms passes
fetch('/api/search?q=rea')
Only the final query fires a request
Response
setResults(data)
Results displayed for 'rea'
What developers doWhat they should do
Thinking useEffect with [] is componentDidMount
The mental model matters: componentDidMount implies 'run once'. Sync mental model leads to correct dependency arrays.
Think of useEffect as synchronization — [] means 'sync with nothing that changes'
Suppressing exhaustive-deps lint warnings
Missing dependencies cause stale closures. The linter catches real bugs. Suppressing it hides them.
Fix the root cause — restructure the effect, move variables inside, or use functional updaters
Using objects/arrays as dependencies without memoization
New object references on every render cause the effect to re-run every render, potentially creating infinite loops.
Depend on primitives, or memoize with useMemo
Not cleaning up subscriptions, timers, or async operations
Without cleanup, old subscriptions stack up, timers fire after unmount, and stale async results overwrite fresh data.
Return a cleanup function that cancels everything the setup created
Quiz
When does the cleanup function of useEffect run?
Quiz
Why does this cause an infinite loop: useEffect(() => { setData(transform(raw)) }, [raw]) when raw is an object prop?
Quiz
What is the purpose of the 'ignore' flag pattern in async effects?
Key Rules
  1. 1useEffect is synchronization, not lifecycle — think 'keep in sync with', not 'run on mount'
  2. 2Cleanup runs before next effect AND on unmount — tear down old before building new
  3. 3Every value from component scope used in the effect must be in the dependency array
  4. 4Depend on primitives, not objects/arrays — or memoize them to prevent infinite loops
  5. 5Always clean up async operations with ignore flags or AbortController

Challenge: Fix the Race Condition

Challenge: Async Effect Race Condition

// This component has a race condition. Fast network responses
// for old queries can overwrite slow responses for new queries.
// Fix it using the cleanup pattern.

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    setLoading(true);
    fetch(\`/api/users/\${userId}\`)
      .then(res => res.json())
      .then(data => {
        setUser(data);
        setLoading(false);
      });
  }, [userId]);

  if (loading) return <p>Loading...</p>;
  return <div>{user.name}</div>;
}
Show Answer
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    const controller = new AbortController();
    setLoading(true);

    async function loadUser() {
      try {
        const res = await fetch(`/api/users/${userId}`, {
          signal: controller.signal,
        });
        const data = await res.json();
        setUser(data);
        setLoading(false);
      } catch (err) {
        if (err.name !== 'AbortError') {
          console.error('Failed to load user:', err);
          setLoading(false);
        }
        // AbortError is expected on cleanup — do nothing
      }
    }

    loadUser();

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

  if (loading) return <p>Loading...</p>;
  return <div>{user.name}</div>;
}

When userId changes, the cleanup aborts the in-flight request for the old userId. The AbortError is caught and ignored. Only the response for the current userId updates state.