Class Components

Before Hooks existed, every React component that needed to hold state or respond to mounting, updating, or unmounting had to be written as an ES6 class extending React.Component. You will still run into class components constantly in older codebases, tutorials, and third-party libraries, and one specific pattern — error boundaries — still requires a class component even in React 19. This lesson explains how class components are written and how they work internally, and maps every piece back to the hooks you already know from the rest of this course, so you can read and maintain legacy React code with confidence.

Overview: How Class Components Work

A class component is a JavaScript class that extends React.Component and implements a render() method. Whatever JSX render() returns is what React puts on the screen for that component. Unlike a function component, a class component gets its props and state attached to the instance via this: this.props holds the props passed down by the parent, and this.state holds the component’s own local state, which you initialize in the constructor and update by calling this.setState().

If you define a constructor, it must call super(props) as the very first line. This runs React.Component‘s own constructor logic and wires up this.props correctly — skip it and this is not usable yet, so accessing this.state or this.props before super(props) throws an error. The constructor is also the only place you assign this.state directly as a plain object; everywhere else, you update it through this.setState(), never by mutating this.state in place.

Calling this.setState() does two things: it merges the object you pass into the existing state (shallow merge, not replace — keys you don’t mention are left alone), and it schedules a re-render. React then calls render() again with the new this.state, diffs the returned JSX against what’s currently on screen (reconciliation), and commits only the necessary DOM changes. This is the exact same render-reconcile-commit cycle you already know from function components; class components just expose it through instance methods and lifecycle callbacks instead of hooks.

Lifecycle methods and their hook equivalents

Class components hook into specific moments of a component’s life using named methods React calls automatically. If you’ve learned useEffect already, this table shows how the two models line up:

Class API Hook equivalent When it runs
constructor + this.state useState Once, before the first render
componentDidMount useEffect(() => {...}, []) Once, right after the first render commits to the DOM
componentDidUpdate useEffect(() => {...}, [deps]) After every re-render caused by new props or state
componentWillUnmount the cleanup function returned from useEffect Right before the component is removed from the DOM
this.setState(updater) the setter returned by useState Whenever you need to update local state
this.context / static contextType useContext Reading a Context value
React.PureComponent / shouldComponentUpdate React.memo Skipping re-renders when props are shallowly equal

Syntax

class ComponentName extends React.Component {
  constructor(props) {
    super(props);
    this.state = { /* initial values */ };
  }

  componentDidMount() { /* runs once after mount */ }
  componentDidUpdate(prevProps, prevState) { /* runs after updates */ }
  componentWillUnmount() { /* cleanup before removal */ }

  render() {
    return <div>{this.state.someValue}</div>;
  }
}
  • extends React.Component — required; makes the class a React component with access to this.props, this.state, and this.setState.
  • constructor(props) — optional; only needed if you use state or bind methods. Must call super(props) first.
  • this.state — a plain object holding local state; assigned directly only inside the constructor.
  • render() — the only required method; must return JSX (or null), and must be a pure function of this.props and this.state — no side effects here.
  • Lifecycle methods — optional methods React calls automatically at specific points; this is where side effects belong.

Examples

Example 1: A simple stateless class component

import React from "react";

class Greeting extends React.Component {
  render() {
    return <h2>Hello, {this.props.name}!</h2>;
  }
}

export default Greeting;

Rendering <Greeting name="Ava" /> produces an <h2> reading “Hello, Ava!”. This component has no state and no lifecycle methods — it only implements the required render() method and reads this.props.name, making it the class equivalent of a plain function component that just returns JSX from its props.

Example 2: State and an event handler

import React from "react";

class Counter extends React.Component {
  constructor(props) {
    super(props);
    this.state = { count: 0 };
  }

  increment = () => {
    this.setState((prevState) => ({ count: prevState.count + 1 }));
  };

  render() {
    return (
      <div>
        <p>Count: {this.state.count}</p>
        <button onClick={this.increment}>Increment</button>
      </div>
    );
  }
}

export default Counter;

This renders a paragraph reading “Count: 0” and a button. Each click calls increment, which uses the updater-function form of setState (recommended when the new state depends on the old state) to bump count by one and trigger a re-render, so the displayed count goes 0, 1, 2, and so on. Note that increment is written as an arrow function assigned to a class field (increment = () => {...}); arrow class fields capture this from the surrounding class automatically, so no manual binding is needed — more on why that matters in Common Mistakes below.

Example 3: Lifecycle methods (mount, update, unmount)

import React from "react";

class Timer extends React.Component {
  constructor(props) {
    super(props);
    this.state = { seconds: 0 };
  }

  componentDidMount() {
    this.intervalId = setInterval(() => {
      this.setState((prevState) => ({ seconds: prevState.seconds + 1 }));
    }, 1000);
  }

  componentDidUpdate(prevProps, prevState) {
    if (prevState.seconds !== this.state.seconds) {
      console.log(`Tick: ${this.state.seconds}`);
    }
  }

  componentWillUnmount() {
    clearInterval(this.intervalId);
  }

  render() {
    return <p>Elapsed: {this.state.seconds}s</p>;
  }
}

export default Timer;

This renders “Elapsed: 0s” immediately, then updates once per second as this.state.seconds increases.

Output (console):

Tick: 1
Tick: 2
Tick: 3

componentDidMount starts the interval exactly once, right after the first render commits — the class equivalent of useEffect(() => {...}, []). Every subsequent render fires componentDidUpdate, which logs a message. componentWillUnmount clears the interval before the component is removed, preventing it from continuing to run (and leaking memory) after the component is gone — the same job a useEffect cleanup function does.

Example 4: Error boundaries — the one job only a class can do

import React from "react";

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error) {
    return { hasError: true };
  }

  componentDidCatch(error, info) {
    console.error("Caught by ErrorBoundary:", error, info.componentStack);
  }

  render() {
    if (this.state.hasError) {
      return <p>Something went wrong.</p>;
    }
    return this.props.children;
  }
}

export default ErrorBoundary;

Used as <ErrorBoundary><BuggyComponent /></ErrorBoundary>, this renders BuggyComponent normally until it throws during rendering. At that point React calls getDerivedStateFromError to flip hasError to true, so instead of the whole app crashing, the boundary re-renders showing “Something went wrong.” while componentDidCatch logs the error and component stack to the console. As of React 19 there is still no hook-based equivalent of an error boundary, so this is the one situation where you’ll deliberately write a class component in otherwise all-hooks, modern code.

How It Works Step by Step

On mount: React calls new ComponentClass(props), running your constructor (and therefore super(props) and any initial this.state assignment). It then calls render() to get the JSX, builds the corresponding DOM nodes, and commits them to the page. Only after that commit does React call componentDidMount, which is why data fetching, subscriptions, and timers belong there — the real DOM node exists by then.

On update: An update is triggered either by the parent passing new props or by a call to this.setState(). React re-invokes render() with the current this.props and this.state, diffs the new JSX tree against the previous one, and commits only the changed DOM nodes. Immediately after that commit, componentDidUpdate(prevProps, prevState) runs, letting you compare old and new values and react accordingly (as in Example 3, where it checks whether seconds actually changed).

On unmount: Just before React removes the component’s DOM nodes for good (because a parent stopped rendering it, or the whole tree unmounted), it calls componentWillUnmount. This is the only place to cancel intervals, close sockets, or unsubscribe from anything you set up in componentDidMount — skipping it causes the exact same leaks a missing useEffect cleanup would.

Common Mistakes

Mistake 1: Forgetting super(props).

class Broken extends React.Component {
  constructor(props) {
    this.state = { count: 0 };
  }
  render() {
    return <p>{this.props.count}</p>;
  }
}

Because super(props) is never called, this is not initialized when the constructor tries to use it, so this throws before the component ever renders. Always call super(props) as the first line of any constructor you write:

constructor(props) {
  super(props);
  this.state = { count: 0 };
}

Mistake 2: Mutating state directly instead of calling setState.

increment = () => {
  this.state.count = this.state.count + 1;
};

This changes the object in memory, but React has no idea a state update happened — it only schedules a re-render in response to this.setState(), so the screen never updates (and if a re-render happens for some unrelated reason, the value will be inconsistent). Always go through setState, preferably with the updater-function form when the new value depends on the old one:

increment = () => {
  this.setState((prevState) => ({ count: prevState.count + 1 }));
};

Mistake 3: Losing the this binding on event handlers.

class Toggle extends React.Component {
  constructor(props) {
    super(props);
    this.state = { on: false };
  }
  handleClick() {
    this.setState({ on: !this.state.on });
  }
  render() {
    return <button onClick={this.handleClick}>Toggle</button>;
  }
}

Passing this.handleClick as a bare reference detaches it from the instance, so when React calls it on click, this inside handleClick is undefined and this.setState(...) throws. Fix it by binding in the constructor (this.handleClick = this.handleClick.bind(this)) or, more simply, by defining the handler as an arrow function class field, which always keeps this bound to the instance, as increment did in Example 2.

Best Practices

  • Write new components as function components with hooks; reach for a class only when maintaining legacy code or building an error boundary.
  • Always call super(props) as the first line of any constructor.
  • Never assign to this.state outside the constructor — use this.setState() everywhere else.
  • Prefer the updater-function form, this.setState((prevState) => ({...})), whenever the new state depends on the previous state, to avoid stale-value bugs from batched updates.
  • Define event handlers as arrow function class fields instead of manually binding in the constructor — it’s shorter and harder to forget.
  • Always clean up subscriptions, intervals, and listeners started in componentDidMount inside componentWillUnmount.
  • Keep render() pure — no setState calls, no side effects, and no direct DOM manipulation inside it.

Practice Exercises

  • Convert the Counter class component from Example 2 into a function component using useState. Confirm it behaves identically when clicked.
  • Write a class component called WindowWidth that stores window.innerWidth in state, updates it on the browser’s resize event using componentDidMount, and removes the listener in componentWillUnmount. (Hint: use window.addEventListener("resize", handler).)
  • The Toggle component in Common Mistakes throws when clicked. Fix it two different ways: once by binding handleClick in the constructor, and once by converting it to an arrow function class field.

Summary

  • A class component extends React.Component and must implement render(), which returns the JSX to display.
  • Constructors that use state must call super(props) first and assign this.state directly only there; every other update goes through this.setState().
  • Lifecycle methods like componentDidMount, componentDidUpdate, and componentWillUnmount map directly onto useEffect‘s mount, update, and cleanup behavior.
  • State must never be mutated directly — always update it through setState, ideally with the updater-function form.
  • Event handlers need their this bound to the instance, either via .bind(this) in the constructor or, more simply, as arrow function class fields.
  • Error boundaries (getDerivedStateFromError / componentDidCatch) are the one feature that still requires a class component in modern React.
  • For all new code, prefer function components with hooks; treat class components as a skill for reading and maintaining existing code.