Engineering Notes
Home/Frontend/React · Core/Component Lifecycle
Frontend · React · Core

Component Lifecycle

Components mount, update and unmount. See how class lifecycle methods map onto useEffect, and watch cleanup run before the next effect when a prop changes, not just on unmount.

🎨 Frontend● Intermediate⏱ 10 min read▶ 1 interactive🛠️ 3 exercises🎤 5 interview Q&As

🎯 What you'll learn

  • Three phases: mount, update, unmount
  • The class component lifecycle
  • The function-component equivalent with useEffect
  • Cleanup timing and what actually runs when

📚 Before you start

1Three Phases, Not a List of Methods

Every component instance goes through the same three phases, regardless of whether it's written as a class or a function:

  1. Mount — the component is created and its output is inserted into the DOM for the first time.
  2. Update — the component re-renders because its props, state, or a context it reads changed (see "Rendering and reconciliation" for exactly what triggers this).
  3. Unmount — the component is removed from the tree and its DOM node is torn down.

Class components exposed these phases as named methods you override. Function components don't have named lifecycle methods at all — useEffect (and friends) let you attach behavior to phases by describing when an effect should re-run, not by naming the phase directly. Both are two different APIs over the exact same underlying phases, produced by the exact same commit-phase mechanics (see section 6).

2The Class Component Lifecycle

tsx
class UserProfile extends React.Component<{ userId: string }, { user: User | null }> {
  constructor(props: { userId: string }) {
    super(props);
    this.state = { user: null }; // set up initial state — runs once, before mount
  }

  componentDidMount() {
    // runs once, right after the component's DOM node is inserted
    fetchUser(this.props.userId).then((user) => this.setState({ user }));
  }

  componentDidUpdate(prevProps: { userId: string }) {
    // runs after every re-render EXCEPT the first — always compare props/state
    // yourself, since this fires on every update, not just the relevant one
    if (prevProps.userId !== this.props.userId) {
      fetchUser(this.props.userId).then((user) => this.setState({ user }));
    }
  }

  componentWillUnmount() {
    // runs once, right before the DOM node is removed — cancel subscriptions,
    // timers, in-flight requests here
    this.pendingRequest?.cancel();
  }

  render() {
    return this.state.user ? <p>{this.state.user.name}</p> : <p>Loading…</p>;
  }
}

Note the shape all three share: render is called to compute what to show, and componentDidMount/componentDidUpdate/componentWillUnmount are called around it to run side effects tied to when it happened. This is the split hooks eventually generalized.

3The Function-Component Equivalent: useEffect

tsx
function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState<User | null>(null);

  useEffect(() => {
    let cancelled = false;
    fetchUser(userId).then((u) => {
      if (!cancelled) setUser(u);
    });
    return () => {
      cancelled = true; // cleanup — this is componentWillUnmount's job, reused
    };
  }, [userId]); // re-run only when userId actually changes

  return user ? <p>{user.name}</p> : <p>Loading…</p>;
}

This one useEffect call replaces componentDidMount and componentDidUpdate and componentWillUnmount at once — see the mapping table in section 4. The full mechanics of the dependency array, cleanup timing, and common bugs are covered in depth in the "UseEffect" topic; this topic focuses on how that mapping lines up with the class model and the commit phase underneath both.

Class lifecycle methods and their useEffect equivalents, lined up against the mount/update/unmount phases and the render/commit split each one fires in
Class lifecycle methods and their useEffect equivalents, lined up against the mount/update/unmount phases and the render/commit split each one fires in

4The Mapping, Precisely

Class methodFiresuseEffect equivalent
constructorOnce, before first renderuseState(initialValue) (lazy initializer form for expensive setup)
renderEvery renderThe function component's own body
componentDidMountOnce, after first commituseEffect(() => { ... }, [])
componentDidUpdateAfter every commit except the firstuseEffect(() => { ... }, [deps]) — runs after mount and whenever a listed dependency changes; you don't get a "not the first time" flag for free, you compare inside the effect if you truly need mount excluded
componentWillUnmountOnce, before removalThe function returned from useEffect's callback
getDerivedStateFromPropsBefore every render, mount and updateComputing a value directly in the render body from props (no effect needed)
shouldComponentUpdateBefore re-rendering, to allow bailing outReact.memo (covered in "Performance optimization")
getSnapshotBeforeUpdateRight before the DOM is mutated, after renderuseLayoutEffect reading the DOM just before your own update (covered in "UseLayoutEffect")

The important asymmetry: a single useEffect with a dependency array covers mount, every relevant update, and unmount cleanup — three class methods collapse into one hook because the hook is keyed on "did this value change," not on "which named phase are we in."

5Cleanup Runs Before the Next Effect, Not Just on Unmount

This is the detail that trips people coming from classes the hardest: the cleanup function isn't only for unmount. React runs it before every re-run of the effect, including ordinary updates — not just the final unmount.

tsx
useEffect(() => {
  const id = setInterval(() => console.log("tick"), 1000);
  return () => clearInterval(id); // runs before the NEXT effect AND on unmount
}, [intervalMs]);

If intervalMs changes, React runs the cleanup from the previous effect (clearing the old interval) before running the new effect (starting a new one with the updated delay) — in that order, every time. On final unmount, there is no "next effect" to run, so only the cleanup fires. This single rule is why useEffect can replace componentDidMount + componentDidUpdate + componentWillUnmount with one function: "clean up the old thing, then set up the new thing" describes an update, and "clean up the old thing, don't set anything up" describes an unmount — same cleanup call, different question of whether a following effect exists.

Under the HoodWhat Actually Runs, and When▾

Fibers carry a tag that tells the commit phase which lifecycle model to run

Every fiber has a numeric tag (ClassComponent, FunctionComponent, HostComponent, etc.). During the commit phase, React switches on this tag to decide what side-effect code to invoke for that fiber — commitClassLifeCycles for class fibers (which directly calls instance.componentDidMount() / instance.componentDidUpdate(prevProps, prevState) on the class instance sitting at fiber.stateNode), versus effect-list processing for function fibers. Class lifecycle methods are not some separate mechanism layered on top of fibers — they are function calls React makes directly against your class instance, from inside the same commit walk that also updates the DOM.

Function-component effects live on fiber.updateQueue, not memoizedState alone

Each useEffect call appends a hook node to the fiber's hooks linked list (memoizedState, described in the "State" topic's Under the Hood section) and an effect object — { tag, create, destroy, deps } — onto a separate circular list at fiber.updateQueue. tag records whether this is a passive effect (useEffect) or a layout effect (useLayoutEffect); create is your callback; destroy is whatever your callback returned last time (or undefined); deps is the array React compares next render.

Dependency comparison happens before the effect list is even scheduled

On each render, React compares the new deps array against the previous one element-by-element with Object.is (not the array reference itself — a fresh [userId] array every render is fine, it's userId that must be stable). If every element matches, React tags this effect as "no work needed" and its create/destroy are skipped entirely for this commit — the hook node's deps are still updated, but nothing runs. This is exactly the same Object.is comparator used for React.memo's props check (see "Rendering and reconciliation"); it's one general mechanism reused in multiple hooks.

Commit order: mutations → layout effects (sync) → paint → passive effects (async)

The commit phase is not "run everything for this fiber, then move to the next fiber." It's three sweeps across the whole tree of committed fibers, in this order:

  1. Mutation sweep — DOM insertions/updates/removals happen. For unmounting fibers, this is also where componentWillUnmount and any useLayoutEffect cleanup fire — bottom-up, children before their parents, because a parent's DOM node might still be needed as a reference point for removing its children first. Both run synchronously, before anything paints.
  2. Layout sweep — remaining useLayoutEffect callbacks and componentDidMount/componentDidUpdate run here, synchronously, before the browser paints. This is why layout effects can read/write layout without a visible flash — nothing has been painted yet.
  3. Passive sweep — useEffect callbacks run here, asynchronously, scheduled after the browser has painted. A useEffect cleanup for an unmounting component is deferred into this same asynchronous sweep too, not the synchronous mutation sweep — which is why a useEffect cleanup can observably fire later than a sibling class's componentWillUnmount for the same removal, even though both are "unmount cleanup."
text
commit:
  mutation sweep   → DOM changes; componentWillUnmount + useLayoutEffect cleanup (bottom-up, sync)
  layout sweep     → useLayoutEffect setup, componentDidMount/Update            (sync, pre-paint)
  ── browser paints ──
  passive sweep    → useEffect setup; useEffect cleanup for unmounted trees too  (async, post-paint)

Why unmount cleanup order is bottom-up

Because the mutation sweep must remove a parent's DOM node only after its children have already been detached (or the child's own cleanup — which might try to read parentNode, or unsubscribe from something the parent set up — would run against a half-torn-down tree), React always unmounts a subtree depth-first, calling every descendant's cleanup/componentWillUnmount before it calls the parent's. This isn't a documented "nice to know" — it's a direct consequence of doing a single DOM removal pass without leaving dangling references mid-walk.

⚠️ Common Pitfalls

  1. Expecting componentDidUpdate (or a bare useEffect(fn) with no dependency array) to run only when "the relevant thing" changed — componentDidUpdate fires after every update regardless of cause, and an effect with no dependency array runs after every single render. Both require you to compare old vs. new yourself, or supply a dependency array, if you only care about specific changes.
  2. Putting side effects directly in the render/component body instead of componentDidMount/useEffect** — the render phase (or a function component's body) can run more than once per commit or be thrown away entirely (see "Rendering and reconciliation"); only commit-phase hooks are guaranteed to run exactly once per actual DOM update.
  3. Forgetting componentWillUnmount/effect cleanup entirely — subscriptions, intervals, and event listeners set up in componentDidMount/useEffect keep running (and keep references alive) after the component is gone, unless you tear them down in the matching cleanup.
  4. Missing a dependency in useEffect's array — the effect keeps closing over a stale value from whatever render created it (this is the JavaScript Closures topic's "closures capture variables, not values" playing out inside a hook); the effect doesn't magically see updated outer variables just because they changed elsewhere.
  5. Assuming cleanup only runs on unmount — as section 5 explains, it runs before every subsequent effect re-run too; code that assumes "this only happens once, at the very end" will run far more often than expected for a changing dependency.
  6. Calling setState unconditionally inside componentDidUpdate without a guard — since componentDidUpdate fires after every update including the one this setState itself causes, an unguarded call creates an infinite re-render loop. The functional equivalent, an unconditional setState inside useEffect with no dependency array, has the identical failure mode.

📋 Quick Reference

PhaseClass method(s)Hook equivalentRuns during
Mountconstructor, render, componentDidMountfunction body runs, then useEffect(fn, [])render phase, then layout/passive sweep
Updaterender, componentDidUpdatefunction body runs, then useEffect(fn, [deps]) if deps changedrender phase, then layout/passive sweep
UnmountcomponentWillUnmountcleanup function returned from useEffectmutation sweep, bottom-up
Bail out of updatingshouldComponentUpdateReact.memobefore the render phase even calls the function
Read DOM right before it changesgetSnapshotBeforeUpdateuseLayoutEffectlayout sweep, pre-paint
text
useEffect(() => {
  setup();
  return () => cleanup(); // runs before next effect, AND on unmount
}, [deps]);

deps omitted   -> runs after every render (mount + every update)
deps = []      -> runs once, after mount only
deps = [a, b]  -> runs after mount, and after any render where a or b changed
                  (Object.is comparison, element by element)

🧠 Key takeaways

  • Phases: mount, update, unmount; think in synchronization, not lifecycle methods.
  • componentDidMount/DidUpdate/WillUnmount ≈ one useEffect with dependencies and cleanup.
  • Cleanup runs before the next effect (with old values) and on unmount.
  • StrictMode double-invokes effects in dev to catch missing cleanups.

🎤 Interview Q&A

Questions interviewers ask about this topic. Answer out loud first, then open a card to compare.

Q1What are the phases of a component's lifecycle?core▾

Mount (first render and insertion), update (re-renders due to state/props/context), unmount (removal). In function components, effects run after mount/updates and their cleanups run before re-running and on unmount.

Follow-ups interviewers ask
  • Class equivalents? componentDidMount, componentDidUpdate, componentWillUnmount.
Q2How do you map class lifecycle methods to hooks?core▾

useEffect(fn, []) ≈ mount (with cleanup ≈ unmount); useEffect(fn, [deps]) ≈ update when deps change; useLayoutEffect for synchronous DOM measurements; error boundaries still require classes (or libraries).

Follow-ups interviewers ask
  • getDerivedStateFromProps equivalent? Usually compute during render or reset via key.
Q3Why does StrictMode mount components twice in development?core▾

To surface effects without proper cleanup and impure renders: React mounts, unmounts and remounts components, so missing cleanups (subscriptions, timers) show up as bugs.

Follow-ups interviewers ask
  • Does it happen in production? No.
Q4What are error boundaries?senior▾

Class components with getDerivedStateFromError/componentDidCatch that catch rendering errors in their subtree and show fallback UI. They don't catch errors in event handlers, async code or SSR.

Follow-ups interviewers ask
  • Library? react-error-boundary.
Q5How do you avoid memory leaks on unmount?core▾

Clean up in effect returns: unsubscribe, clear timers/intervals, abort fetches (AbortController), remove event listeners.

Follow-ups interviewers ask
  • Symptom? Updates to unmounted components or growing memory in long sessions.

🛠️ Practice exercises

Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.

✅ Check yourself

Answer the questions — feedback is instant.

1. roomId changes. Order of operations?
Cleanup first.
2. componentWillUnmount maps to…
Return value.
3. Why does an effect run twice in development?
Dev-only check.
4. When do effects run relative to paint?
useLayoutEffect runs before paint.