Skip to content

Component Lifecycle and Effects

intermediate11 min read

The Three Phases of a Component's Life

Every React component goes through three phases:

  1. Mount — Component appears in the tree for the first time
  2. Update — Props or state change, component re-renders
  3. Unmount — Component is removed from the tree

In class components, these mapped to explicit lifecycle methods (componentDidMount, componentDidUpdate, componentWillUnmount). In function components, useEffect handles all three.

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

  useEffect(() => {
    // Runs after mount AND after userId changes
    let cancelled = false;

    async function fetchUser() {
      const response = await fetch(`/api/users/${userId}`);
      const data = await response.json();
      if (!cancelled) {
        setUser(data);
      }
    }

    fetchUser();

    return () => {
      // Cleanup: runs before next effect AND on unmount
      cancelled = true;
    };
  }, [userId]);

  if (!user) return <Skeleton />;
  return <div>{user.name}</div>;
}
Mental Model

Think of useEffect as a synchronization mechanism, not a lifecycle hook. It synchronizes your component with an external system — the network, the DOM, a timer, a subscription. The dependency array tells React: "re-synchronize whenever these values change." The cleanup function tells React: "here is how to undo the previous synchronization before starting a new one."

When useEffect Runs

useEffect runs after the browser paints. This is critical timing:

Render → Commit (DOM update) → Browser Paint → useEffect
function Timer() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    // This runs AFTER the browser has painted count to the screen.
    // The user sees the updated count before this code runs.
    console.log('Effect: count is', count);
  });

  console.log('Render: count is', count);

  return <div>{count}</div>;
}

// Output order on mount:
// "Render: count is 0"
// (browser paints "0" on screen)
// "Effect: count is 0"

This timing means the user never sees an intermediate state caused by the effect. The screen is already painted when the effect runs.

The Dependency Array

The dependency array controls when the effect re-runs:

// Runs after EVERY render
useEffect(() => { /* ... */ });

// Runs only on mount (empty deps)
useEffect(() => { /* ... */ }, []);

// Runs on mount and whenever userId or query changes
useEffect(() => { /* ... */ }, [userId, query]);
Common Trap

The empty dependency array [] does not mean "run once." It means "this effect has no dependencies — it synchronizes with nothing that changes." If you find yourself adding // eslint-disable-next-line react-hooks/exhaustive-deps to suppress the linter, your effect is almost certainly broken. The linter knows which values from the closure the effect uses. If the effect reads userId but userId is not in the deps array, the effect captures a stale userId forever.

Cleanup: The Most Misunderstood Part

The cleanup function returned from useEffect runs:

  1. Before the next effect (when dependencies change)
  2. On unmount (when the component leaves the tree)
function ChatRoom({ roomId }) {
  useEffect(() => {
    const connection = createConnection(roomId);
    connection.connect();
    console.log('Connected to', roomId);

    return () => {
      connection.disconnect();
      console.log('Disconnected from', roomId);
    };
  }, [roomId]);

  return <Chat />;
}

// User switches from room "general" to room "react":
// 1. "Connected to general"  (mount)
// 2. "Disconnected from general"  (cleanup of previous effect)
// 3. "Connected to react"  (new effect)
Strict Mode double-invocation

In development with Strict Mode, React mounts every component twice: mount → unmount → mount. This means your effect runs, cleanup runs, then the effect runs again. This is intentional — it catches effects that do not clean up properly. If your component breaks on the second mount, your cleanup is incomplete. In production, components mount once. This double-invocation only happens in development.

The Infinite Loop Trap

The most common useEffect bug:

// INFINITE LOOP:
function UserList() {
  const [users, setUsers] = useState([]);

  useEffect(() => {
    fetch('/api/users')
      .then(res => res.json())
      .then(data => setUsers(data)); // Triggers re-render
  }); // No dependency array → runs after EVERY render

  // setUsers causes re-render → effect runs again → setUsers → re-render → ...
}

// Fix: Add dependency array
useEffect(() => {
  fetch('/api/users')
    .then(res => res.json())
    .then(data => setUsers(data));
}, []); // Only on mount

Another subtle infinite loop:

// ALSO INFINITE LOOP:
function Search({ query }) {
  const [results, setResults] = useState([]);

  const options = { query, limit: 10 }; // New object every render!

  useEffect(() => {
    fetchResults(options).then(setResults);
  }, [options]); // options is a new reference every render → effect re-runs → infinite

  // Fix: Depend on primitive values, not object references
  useEffect(() => {
    fetchResults({ query, limit: 10 }).then(setResults);
  }, [query]); // Stable primitive dependency
}

Production Scenario: Race Condition Prevention

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

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

    async function search() {
      const data = await fetchResults(query);
      if (!ignore) {
        setResults(data);
      }
    }

    search();

    return () => {
      ignore = true; // Prevent stale response from overwriting fresh data
    };
  }, [query]);

  return <ResultList results={results} />;
}

// User types "re" → "rea" → "reac" → "react" quickly
// Each keystroke triggers a new effect, which cancels the previous.
// Without the ignore flag, a slow "re" response arriving after a fast
// "react" response would overwrite the correct results.
Execution Trace
Mount:
Component enters tree, initial render
Render → DOM update → Paint
Effect:
useEffect callback runs
After paint — user sees initial UI
State change:
Props or state update triggers re-render
New render cycle begins
Cleanup:
Previous effect's cleanup runs
Before new effect, old synchronization is undone
New effect:
New effect callback runs with fresh values
Synchronized with current state
Unmount:
Component leaves tree
Final cleanup runs — disconnect, unsubscribe, cancel
Common Mistakes
  • Wrong: Using useEffect for derived state: useEffect(() => setFullName(first + last), [first, last]) Right: Compute during render: const fullName = first + ' ' + last

  • Wrong: Missing dependency array — effect runs on every render Right: Always include the dependency array. Use [] for mount-only, or list all values the effect reads.

  • Wrong: Suppressing the exhaustive-deps lint rule Right: Fix the effect structure — restructure to include all dependencies, or move logic into the effect

  • Wrong: Not cleaning up subscriptions and timers Right: Return a cleanup function that unsubscribes, clears timers, or sets cancel flags

Quiz
When does the useEffect callback run relative to the browser paint?
Quiz
What happens with useEffect(() => { ... }) — no dependency array?
Quiz
In React Strict Mode (development), how many times does a mount effect run?
Key Rules
  1. 1useEffect runs AFTER browser paint — it is for synchronization with external systems, not for computed values
  2. 2The dependency array lists every value from the component scope that the effect reads — never lie to React about deps
  3. 3Cleanup runs before the next effect and on unmount — use it for subscriptions, timers, and cancel flags
  4. 4Derived state does not need useEffect — compute it during render
  5. 5Strict Mode double-invokes effects in development to catch missing cleanup

Challenge: Fix the Memory Leak

Effect Cleanup Debugging