Component Lifecycle (Class)

Difficulty: Beginner

Question

Explain the React component lifecycle. How do class lifecycle methods map to hooks?

Answer

The component lifecycle is one of those foundational concepts that you absolutely need to understand, even though modern React code mostly uses hooks instead of class lifecycle methods. Here's why: hooks are abstractions on top of the lifecycle, and when something goes wrong - when a useEffect fires at the wrong time, when cleanup doesn't work as expected, when re-renders happen unexpectedly - you need to understand what's happening underneath to debug it effectively. Plus, a huge amount of production React code out there is still class-based, and you'll encounter it.

So let's walk through the lifecycle of a React class component. Think of it like the life stages of a living thing: it's born (mounting), it grows and changes (updating), and eventually it dies (unmounting).

Mounting is when the component instance is created and inserted into the DOM for the first time. The methods fire in this order:

1. constructor(props) - This is where you initialize state and bind methods. You call super(props) first (required in JavaScript class inheritance), then set up this.state. A common mistake is calling setState here - don't. Just assign to this.state directly in the constructor.

2. static getDerivedStateFromProps(props, state) - This is a rarely-used static method that lets you update state based on incoming props. It runs before every render, both on mount and updates. Honestly, if you find yourself reaching for this method, there's usually a better pattern. The React team themselves say it's for "rare use cases."

3. render() - This is the only required method in a class component. It returns JSX describing what should appear on screen. Critically, render must be a pure function - no side effects, no setState calls, no API requests. Just take props and state, return JSX.

4. componentDidMount() - This fires once, after the component has been rendered to the DOM for the first time. This is your go-to place for side effects: API calls to fetch initial data, setting up subscriptions or event listeners, starting timers, integrating with third-party DOM libraries. If you need data from a server, this is where you fetch it. Not in the constructor, not in render - here.

Updating happens whenever the component re-renders due to new props, setState, or forceUpdate. The methods fire in this order:

1. static getDerivedStateFromProps() - Same as during mounting. Rarely needed.

2. shouldComponentUpdate(nextProps, nextState) - This is your performance escape hatch. It receives the incoming props and state, and you return true (proceed with re-render) or false (skip it). By default, React re-renders on every setState call, even if the actual values didn't change. This method lets you compare old and new values and skip unnecessary renders. React.PureComponent does this automatically with a shallow comparison, and in function components, React.memo serves the same purpose.

3. render() - Computes the new JSX based on updated props and state.

4. getSnapshotBeforeUpdate(prevProps, prevState) - Fires right after render but before the DOM is updated. It lets you capture information from the DOM (like scroll position) before it potentially changes. Whatever you return from this method gets passed as the third argument to componentDidUpdate. This is a niche method, mostly used for things like chat windows that need to maintain scroll position when new messages are added.

5. componentDidUpdate(prevProps, prevState, snapshot) - Fires after every update. This is where you respond to prop or state changes with side effects. But here's the critical thing: you must always guard your logic with a comparison. If you call setState unconditionally inside componentDidUpdate, you create an infinite loop (update triggers componentDidUpdate, which triggers setState, which triggers another update, and so on). Always write something like if (prevProps.userId !== this.props.userId) { fetchUser(this.props.userId); }.

Unmounting is when the component is removed from the DOM:

1. componentWillUnmount() - Your cleanup method. Cancel timers, remove event listeners, abort network requests, unsubscribe from WebSocket connections - anything your component set up that won't clean itself up automatically. Forgetting cleanup is one of the most common sources of memory leaks in React apps. If you ever see a warning like "Can't perform a React state update on an unmounted component," it usually means you forgot to clean something up here.

Now, how does all of this map to hooks? A single useEffect replaces three lifecycle methods. An effect with an empty dependency array is roughly componentDidMount - it runs once after the first render. An effect with specific dependencies is roughly componentDidUpdate for those specific values - it re-runs when they change. And the cleanup function returned from useEffect is componentWillUnmount (plus it runs before each re-run of the effect, which is actually a superpower that class components didn't have - it means your cleanup is always paired with the specific effect run that created it).

But there's a subtle difference to know: useEffect runs asynchronously, after the browser has painted. componentDidMount runs synchronously, before the browser paints. In most cases this doesn't matter, but if you're doing DOM measurements or visual adjustments that must happen before the user sees anything, you need useLayoutEffect, which has the same timing as componentDidMount.

shouldComponentUpdate maps to React.memo for function components. You wrap your function component in React.memo(), and React will skip re-rendering it if its props haven't changed (shallow comparison by default, or you can provide a custom comparison function).

One thing that still requires class components: Error Boundaries. componentDidCatch and getDerivedStateFromError are the only lifecycle methods that have no hooks equivalent. If you need to catch JavaScript errors in a component tree and display a fallback UI, you must write a class component. There have been discussions about adding a useErrorBoundary hook, but as of now, Error Boundaries remain class-only territory.

Code examples

Class Lifecycle Methods

class UserProfile extends React.Component {
  constructor(props) {
    super(props);
    this.state = { user: null, loading: true };
  }

  componentDidMount() {
    // Runs once after first render - fetch data here
    fetch(`/api/users/${this.props.userId}`)
      .then(res => res.json())
      .then(user => this.setState({ user, loading: false }));
  }

  componentDidUpdate(prevProps) {
    // Runs after every update - compare props to avoid infinite loops
    if (prevProps.userId !== this.props.userId) {
      this.setState({ loading: true });
      fetch(`/api/users/${this.props.userId}`)
        .then(res => res.json())
        .then(user => this.setState({ user, loading: false }));
    }
  }

  componentWillUnmount() {
    // Cleanup subscriptions, timers, etc.
    console.log('Cleaning up...');
  }

  render() {
    if (this.state.loading) return <p>Loading...</p>;
    return <h1>{this.state.user.name}</h1>;
  }
}

componentDidMount fires once after mount (API calls). componentDidUpdate fires after each update (must guard with conditions). componentWillUnmount is for cleanup.

Equivalent with Hooks

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

  // Combines componentDidMount + componentDidUpdate
  useEffect(() => {
    setLoading(true);
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(user => {
        setUser(user);
        setLoading(false);
      });

    // Combines componentWillUnmount
    return () => {
      console.log('Cleaning up...');
    };
  }, [userId]); // Re-runs when userId changes

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

One useEffect replaces componentDidMount, componentDidUpdate, and componentWillUnmount. The dependency array controls when the effect re-runs.

shouldComponentUpdate vs React.memo

// Class: shouldComponentUpdate
class ExpensiveComponent extends React.Component {
  shouldComponentUpdate(nextProps) {
    // Only re-render if items actually changed
    return nextProps.items !== this.props.items;
  }

  render() {
    return (
      <ul>
        {this.props.items.map(item => (
          <li key={item.id}>{item.name}</li>
        ))}
      </ul>
    );
  }
}

// Function component equivalent: React.memo
const ExpensiveComponent = React.memo(function({ items }) {
  return (
    <ul>
      {items.map(item => (
        <li key={item.id}>{item.name}</li>
      ))}
    </ul>
  );
});

// Custom comparison
const ExpensiveComponent = React.memo(
  function({ items }) { /* render */ },
  (prevProps, nextProps) => {
    return prevProps.items.length === nextProps.items.length;
  }
);

shouldComponentUpdate in class components maps to React.memo in function components. Both prevent unnecessary re-renders by comparing props.

Key points

Concepts covered

Lifecycle Methods, Class Components, Mounting, Updating, Unmounting