Type something to search...
Understanding the React Component Lifecycle in the Hooks Era

Understanding the React Component Lifecycle in the Hooks Era

If you learned React with class components, you probably remember the lifecycle diagram: componentDidMount, componentDidUpdate, componentWillUnmount, and a handful of rarer methods in between. Function components don't have any of those methods, yet they still mount, update, and unmount. The lifecycle didn't disappear. It just stopped being something you hook into by name.

That shift trips people up. Developers coming from classes try to recreate componentDidMount with an empty dependency array and then wonder why their effect sees stale values. Developers who started with hooks often don't have a clear picture of when their code actually runs, which makes bugs around timing, cleanup, and re-renders hard to reason about.

This post walks through the lifecycle of a function component from the first render to removal from the screen. You'll see what happens during the render phase and the commit phase, how useEffect, useLayoutEffect, and useRef fit in, how the old class methods map to hooks, and which mental model makes the whole thing easier to think about.

The Three Phases: Mount, Update, Unmount

Every component instance goes through the same broad stages:

  1. Mount. React calls your component for the first time, builds its output, and inserts the result into the DOM.
  2. Update. Something changes (state, props, or context), React calls your component again, compares the new output to the previous one, and applies the differences.
  3. Unmount. The component is removed from the tree, and React runs any cleanup you registered.

Inside each of those stages, React splits work into two phases:

  • Render phase. React calls your component function to find out what the UI should look like. This must be pure: no subscriptions, no DOM mutations, no network requests. React may call your component more than once or throw the result away.
  • Commit phase. React applies the changes to the DOM, attaches refs, and then runs your effects.

Keeping those two phases separate in your head solves most lifecycle confusion. The body of your component is render-phase code. Effects are commit-phase code.

What Happens on Mount

Here's a small component that logs each step so you can watch the order:

import { useEffect, useLayoutEffect, useRef, useState } from "react";

export function LifecycleLogger({ label }: { label: string }) {
  console.log(`[${label}] render`);

  const [count, setCount] = useState(() => {
    console.log(`[${label}] lazy initial state`);
    return 0;
  });

  const buttonRef = useRef<HTMLButtonElement>(null);

  useLayoutEffect(() => {
    console.log(`[${label}] layout effect, button in DOM:`, !!buttonRef.current);
    return () => console.log(`[${label}] layout cleanup`);
  });

  useEffect(() => {
    console.log(`[${label}] effect`);
    return () => console.log(`[${label}] effect cleanup`);
  });

  return (
    <button ref={buttonRef} onClick={() => setCount((c) => c + 1)}>
      {label}: {count}
    </button>
  );
}

On the first mount (outside Strict Mode), the console shows:

[demo] render
[demo] lazy initial state
[demo] layout effect, button in DOM: true
[demo] effect

Step by step:

  1. React calls LifecycleLogger. The lazy initializer passed to useState runs once, on mount only.
  2. React commits the button to the DOM and assigns buttonRef.current.
  3. useLayoutEffect runs synchronously after the DOM is updated but before the browser paints.
  4. The browser paints.
  5. useEffect runs, usually shortly after paint.

The key takeaway is that refs are only populated during commit. If you read buttonRef.current in the component body on the first render, it's null. Read it in an effect or an event handler instead.

What Happens on Update

Click the button and React re-renders. The log looks like this:

[demo] render
[demo] layout cleanup
[demo] layout effect, button in DOM: true
[demo] effect cleanup
[demo] effect

Notice two things. First, the lazy initializer doesn't run again; state is preserved between renders. Second, React runs the previous effect's cleanup before running the new effect. Cleanup isn't just an unmount thing. Every time an effect re-runs, the last version cleans up first.

This is why an effect with dependencies is better described as "synchronize with these values" rather than "run on update." When userId changes, the subscription for the old userId is torn down and a new one for the current userId is created.

What Triggers an Update

A component re-renders when:

  • It calls a state setter with a value that's different from the current one (compared with Object.is).
  • Its parent re-renders. By default, children re-render whenever the parent does, even if their props are the same.
  • A context it reads with useContext or use changes value.
  • An external store it subscribes to with useSyncExternalStore changes.

Props changing is not really a separate trigger. Props only change because the parent rendered again. If you want to skip re-rendering when props are equal, wrap the child in memo, as covered in preventing unnecessary re-renders with React.memo.

What Happens on Unmount

When the component is removed, React runs every remaining cleanup function:

[demo] layout cleanup
[demo] effect cleanup

Then the component's state is discarded. If the same component type appears again later in the same place, it starts fresh with new state.

Here's a more realistic example: a component that subscribes to window resize events and cleans up after itself.

import { useEffect, useState } from "react";

export function WindowWidth() {
  const [width, setWidth] = useState(() => window.innerWidth);

  useEffect(() => {
    function handleResize() {
      setWidth(window.innerWidth);
    }

    window.addEventListener("resize", handleResize);
    return () => window.removeEventListener("resize", handleResize);
  }, []);

  return <p>Window width: {width}px</p>;
}

The listener is added after the first commit and removed on unmount. Without the cleanup, every mount would add another listener that keeps calling setWidth on a component that no longer exists.

Mapping Class Lifecycle Methods to Hooks

It's useful to know the rough equivalents, as long as you don't treat them as exact translations.

Class methodHooks equivalent
constructoruseState / useReducer initial value, useRef
componentDidMountuseEffect with [] dependencies
componentDidUpdateuseEffect with specific dependencies
componentWillUnmountcleanup function returned from useEffect
shouldComponentUpdatememo around the component
getDerivedStateFromPropscompute during render, or adjust state during render
getSnapshotBeforeUpdateuseLayoutEffect (approximately)
componentDidCatchno hook equivalent; use an error boundary class

A class component that fetched data on mount and when an ID changed looked like this:

import { Component } from "react";

type Props = { userId: string };
type State = { name: string | null };

export class UserNameClass extends Component<Props, State> {
  state: State = { name: null };

  componentDidMount() {
    this.load();
  }

  componentDidUpdate(prevProps: Props) {
    if (prevProps.userId !== this.props.userId) {
      this.load();
    }
  }

  async load() {
    const res = await fetch(`/api/users/${this.props.userId}`);
    const user = await res.json();
    this.setState({ name: user.name });
  }

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

The hooks version collapses mount and update into a single effect keyed on userId, and adds the cleanup the class version forgot:

import { useEffect, useState } from "react";

export function UserName({ userId }: { userId: string }) {
  const [name, setName] = useState<string | null>(null);

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

    fetch(`/api/users/${userId}`)
      .then((res) => res.json())
      .then((user: { name: string }) => {
        if (!ignore) setName(user.name);
      });

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

  return <p>{name ?? "Loading..."}</p>;
}

The ignore flag prevents a race condition. If userId changes from 1 to 2 while the first request is still in flight, the cleanup for 1 sets ignore = true, so a slow response for user 1 can't overwrite the name for user 2. The class version has exactly that bug. For production data fetching, a library like TanStack Query handles this for you, but the pattern is worth understanding.

Think in Synchronization, Not Lifecycle Events

The biggest change hooks brought isn't syntax. It's the mental model.

With classes, you thought in terms of moments: "when the component mounts, subscribe; when it updates, check if something changed; when it unmounts, unsubscribe." That split one concern across three methods.

With hooks, you think in terms of what external thing this component needs to stay in sync with. A chat room connection depends on roomId. A document title depends on unreadCount. Each effect describes one synchronization:

import { useEffect } from "react";
import { createConnection } from "./chat";

export function ChatRoom({ roomId }: { roomId: string }) {
  useEffect(() => {
    const connection = createConnection(roomId);
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]);

  return <h2>Welcome to {roomId}</h2>;
}

You don't need to ask "is this a mount or an update?" The effect starts syncing with the current roomId, and stops syncing when roomId changes or the component goes away. React handles when. You only describe what.

This model is also why React 18 and 19 run effects twice in development under Strict Mode. React is checking that your effect can start and stop cleanly. If you've been confused by that double run, why your useEffect runs twice in React Strict Mode explains it in detail.

Where Each Kind of Code Belongs

With the phases clear, you can decide where any piece of logic goes.

In the Component Body (Render Phase)

  • Computing values from props and state, like filtering a list or formatting a date.
  • Reading context.
  • Deciding what JSX to return.

Keep it pure. Given the same props, state, and context, the body should return the same output and change nothing outside itself.

type Todo = { id: number; text: string; done: boolean };

export function TodoSummary({ todos }: { todos: Todo[] }) {
  // Derived during render, no effect or extra state needed.
  const remaining = todos.filter((t) => !t.done).length;

  return <p>{remaining} tasks left</p>;
}

In Event Handlers

Anything caused by a specific user action: submitting a form, sending an analytics event for a click, navigating after a button press. Event handlers run outside the render cycle, so side effects are fine there. If the code runs because the user did something, it belongs in a handler, not an effect.

In useEffect

Synchronizing with systems outside React: subscriptions, timers, manual DOM integrations with third-party libraries, network connections. The effect runs because the component is on screen with certain values, not because of a specific interaction. The practical guide to useEffect and its dependency array covers how to get the dependencies right.

In useLayoutEffect

DOM measurements that must happen before the user sees the frame, such as positioning a tooltip based on its rendered size. It blocks paint, so use it only when useEffect would cause a visible flicker. See useLayoutEffect vs useEffect for examples.

Resetting a Component's Lifecycle With a Key

Sometimes you want a component to start over completely, for example a profile form that should clear when you switch users. Rather than writing an effect that resets every piece of state when userId changes, give the component a key:

import { useState } from "react";

function ProfileForm({ userId }: { userId: string }) {
  const [draft, setDraft] = useState("");
  return (
    <label>
      Bio for {userId}
      <textarea value={draft} onChange={(e) => setDraft(e.target.value)} />
    </label>
  );
}

export function ProfilePage({ userId }: { userId: string }) {
  return <ProfileForm key={userId} userId={userId} />;
}

When key changes, React treats it as a different component. The old instance unmounts (running all cleanups) and a new one mounts with fresh state. It's the cleanest way to say "this is a new lifecycle."

Common Mistakes With the Hooks Lifecycle

  • Treating [] as "componentDidMount" and ignoring dependencies. If the effect reads props or state, an empty array freezes it to the values from the first render. List what you use.
  • Forgetting cleanup. Subscriptions, intervals, and event listeners must be removed, or they leak and keep firing after unmount.
  • Side effects in the render body. Calling fetch, writing to localStorage, or mutating a global during render makes behavior unpredictable, especially with concurrent rendering and Strict Mode.
  • Reading refs during render. ref.current is only reliable after commit. Use it in effects and handlers.
  • Syncing derived values into state with an effect. If a value can be computed from props or state, compute it during render. An effect that copies it into state causes an extra render and can show stale data for a frame.
  • Expecting effects to run only once in development. Strict Mode mounts, unmounts, and remounts components to surface missing cleanups. Fix the cleanup rather than fighting the check.

Frequently Asked Questions (FAQ) About the React Component Lifecycle

Not as named methods. Function components still mount, update, and unmount, but you respond to those stages with hooks. useEffect and useLayoutEffect run after commits, their cleanup functions run before the next run and on unmount, and state hooks hold values between renders.

The closest equivalent is useEffect with an empty dependency array. It runs after the first commit. In Strict Mode during development, React runs it, cleans it up, and runs it again, so make sure the cleanup fully undoes what the effect did.

After React commits changes to the DOM, and usually after the browser has painted. If the render was caused by a discrete user interaction like a click, React may flush the effect before paint so the result is consistent. Either way, it never runs during rendering.

No. Cleanup runs before every re-run of the effect and once more on unmount. That's what makes effects with dependencies work: the subscription for the old value is removed before the subscription for the new value is created.

A component renders when its state changes, when its parent renders, or when a context it reads changes. In development, Strict Mode also double-invokes component functions to catch impure code. Use React DevTools Profiler to see which update caused each render.

No. Error boundaries still require a class component with componentDidCatch or static getDerivedStateFromError. Most projects use a small library like react-error-boundary or write one boundary class and reuse it everywhere.

Conclusion

A function component's lifecycle is the same mount, update, and unmount sequence classes had, split into a pure render phase and a commit phase where effects run. Component bodies calculate output, event handlers respond to user actions, useEffect synchronizes with external systems, and useLayoutEffect handles the rare measurement that must happen before paint. Cleanup runs before each re-run and on unmount.

The best next step is to stop mapping hooks to class methods and start asking what each effect keeps in sync. Try adding logs like the LifecycleLogger example to a component you're working on, toggle it on and off, and change its props. Seeing the order with your own eyes makes the model stick.

Tags :
Share :

Related Posts

A Practical Guide to useEffect and Its Dependency Array

A Practical Guide to useEffect and Its Dependency Array

useEffect is the hook people get wrong most often, and the dependency array is usually where it goes wrong. Leave a value out and your effect works

Continue Reading
Accessibility Best Practices for React Developers

Accessibility Best Practices for React Developers

React makes it easy to build interfaces out of anything. A div with an onClick looks and behaves like a button for a mouse user, so it ships. The

Continue Reading
Animations in React with Motion (Framer Motion)

Animations in React with Motion (Framer Motion)

CSS transitions get you far, until you need to animate something leaving the page. React removes the element from the DOM immediately, so there's not

Continue Reading