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.
🎯 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
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:
- Mount — the component is created and its output is inserted into the DOM for the first time.
- Update — the component re-renders because its props, state, or a context it reads changed (see "Rendering and reconciliation" for exactly what triggers this).
- 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
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
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.
4The Mapping, Precisely
| Class method | Fires | useEffect equivalent |
|---|---|---|
constructor | Once, before first render | useState(initialValue) (lazy initializer form for expensive setup) |
render | Every render | The function component's own body |
componentDidMount | Once, after first commit | useEffect(() => { ... }, []) |
componentDidUpdate | After every commit except the first | useEffect(() => { ... }, [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 |
componentWillUnmount | Once, before removal | The function returned from useEffect's callback |
getDerivedStateFromProps | Before every render, mount and update | Computing a value directly in the render body from props (no effect needed) |
shouldComponentUpdate | Before re-rendering, to allow bailing out | React.memo (covered in "Performance optimization") |
getSnapshotBeforeUpdate | Right before the DOM is mutated, after render | useLayoutEffect 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.
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:
- Mutation sweep — DOM insertions/updates/removals happen. For unmounting fibers, this is also where
componentWillUnmountand anyuseLayoutEffectcleanup 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. - Layout sweep — remaining
useLayoutEffectcallbacks andcomponentDidMount/componentDidUpdaterun here, synchronously, before the browser paints. This is why layout effects can read/write layout without a visible flash — nothing has been painted yet. - Passive sweep —
useEffectcallbacks run here, asynchronously, scheduled after the browser has painted. AuseEffectcleanup for an unmounting component is deferred into this same asynchronous sweep too, not the synchronous mutation sweep — which is why auseEffectcleanup can observably fire later than a sibling class'scomponentWillUnmountfor the same removal, even though both are "unmount cleanup."
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
- Expecting
componentDidUpdate(or a bareuseEffect(fn)with no dependency array) to run only when "the relevant thing" changed —componentDidUpdatefires 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. - 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. - Forgetting
componentWillUnmount/effect cleanup entirely — subscriptions, intervals, and event listeners set up incomponentDidMount/useEffectkeep running (and keep references alive) after the component is gone, unless you tear them down in the matching cleanup. - 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. - 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.
- Calling
setStateunconditionally insidecomponentDidUpdatewithout a guard — sincecomponentDidUpdatefires after every update including the one thissetStateitself causes, an unguarded call creates an infinite re-render loop. The functional equivalent, an unconditionalsetStateinsideuseEffectwith no dependency array, has the identical failure mode.
📋 Quick Reference
| Phase | Class method(s) | Hook equivalent | Runs during |
|---|---|---|---|
| Mount | constructor, render, componentDidMount | function body runs, then useEffect(fn, []) | render phase, then layout/passive sweep |
| Update | render, componentDidUpdate | function body runs, then useEffect(fn, [deps]) if deps changed | render phase, then layout/passive sweep |
| Unmount | componentWillUnmount | cleanup function returned from useEffect | mutation sweep, bottom-up |
| Bail out of updating | shouldComponentUpdate | React.memo | before the render phase even calls the function |
| Read DOM right before it changes | getSnapshotBeforeUpdate | useLayoutEffect | layout sweep, pre-paint |
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
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.
- 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).
- 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.
- 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.
- 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.
- 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.