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, andthis.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 ofthis.propsandthis.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.stateoutside the constructor — usethis.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
componentDidMountinsidecomponentWillUnmount. - Keep
render()pure — nosetStatecalls, no side effects, and no direct DOM manipulation inside it.
Practice Exercises
- Convert the
Counterclass component from Example 2 into a function component usinguseState. Confirm it behaves identically when clicked. - Write a class component called
WindowWidththat storeswindow.innerWidthin state, updates it on the browser’sresizeevent usingcomponentDidMount, and removes the listener incomponentWillUnmount. (Hint: usewindow.addEventListener("resize", handler).) - The
Togglecomponent in Common Mistakes throws when clicked. Fix it two different ways: once by bindinghandleClickin the constructor, and once by converting it to an arrow function class field.
Summary
- A class component extends
React.Componentand must implementrender(), which returns the JSX to display. - Constructors that use state must call
super(props)first and assignthis.statedirectly only there; every other update goes throughthis.setState(). - Lifecycle methods like
componentDidMount,componentDidUpdate, andcomponentWillUnmountmap directly ontouseEffect‘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
thisbound 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.
