Topic 26.4
Class Components and Legacy Code
In one line
Before hooks, components with state were JavaScript classes with this.state, this.setState and lifecycle methods; you need to read them, explain how each maps to hooks, and migrate them safely, and error boundaries still have to be classes.
Think of it like this
An old car in the family. New cars have push-button starts, but your grandfather's car still has a key and a choke. You don't need to buy one, but if you're the mechanic you must know what each knob does and what it does in the new car. Class components are React's older model: most big codebases still contain them, interviewers still ask about them, and error boundaries can only be written as classes.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Class component
- A component written as a class that extends
Componentand has arender()method. - this.state / this.setState
- A class component's state object and the method that merges changes into it and schedules a render.
- Lifecycle method
- A method React calls at a certain moment: after mounting, after updating, before unmounting.
- componentDidMount
- Runs once after the component first appears in the DOM.
- componentDidUpdate
- Runs after every update, with the previous props and state, so you can compare.
- componentWillUnmount
- Runs just before the component is removed: clean up timers and subscriptions here.
- PureComponent
- A class base that skips rendering when props and state are shallowly equal.
- Error boundary
- A class component that catches render errors in its children and shows a fallback.
- Binding
- Fixing what
thisrefers to inside a method, needed when the method is passed as a handler.
Step by step
01Read a class component
Find four things: where state starts (constructor or state =), what render returns, which methods change state, and which lifecycle methods talk to the outside world.
class Counter extends Component {
state = { count: 0, label: "Clicks" }; // initial state
increment = () => { // arrow field keeps `this`
this.setState((prev) => ({ count: prev.count + 1 })); // merges: label stays
};
componentDidUpdate(prevProps, prevState) {
if (prevState.count !== this.state.count) document.title = "Count " + this.state.count;
}
render() {
return <button onClick={this.increment}>{this.state.label}: {this.state.count}</button>;
}
}02Know the lifecycle order
The live example logs every method. Match each line to this diagram.
03Map each method to hooks
Use the table in the code section. The big idea: lifecycle methods split one job across several methods; an effect keeps one job in one place.
04Rewrite the same component with hooks
State fields become useState calls, the componentDidUpdate comparison becomes a dependency array.
function Counter() {
const [count, setCount] = useState(0);
const label = "Clicks";
useEffect(() => {
document.title = "Count " + count;
}, [count]);
return <button onClick={() => setCount((c) => c + 1)}>{label}: {count}</button>;
}05Migrate safely
1) Write tests for what the user sees. 2) Convert state and handlers. 3) Merge related lifecycle methods into effects with cleanups. 4) Replace instance fields with refs. 5) Run the tests and StrictMode. Keep error boundaries as classes.
Try it yourself
- 1
Return false from shouldComponentUpdate
In the lifecycle example, make
shouldComponentUpdatereturnfalse. Predict the log after the click and what the button shows. (After the click onlygetDerivedStateFromProps,shouldComponentUpdateand the setState callback log: there's norenderand nocomponentDidUpdate, and the button still says 0 likes. The callback showslikes = 1: the state changed, but React skipped rendering it.) - 2
Lose
thisIn the setState example, change
addTwiceRight = () => {to a normal methodaddTwiceRight() {. Click the button in the preview and read the console:thisisundefinedinside the handler. Fix it withonClick={() => this.addTwiceRight()}or by binding in a constructor. - 3
Replace gDSFP with a key
Delete
getDerivedStateFromPropsand theuserIdfield fromEditName, and render<EditName key={users[i].id} user={users[i]} />. The output stays the same: a new key mounts a fresh component with fresh state.
Code & diagrams
Mount, one update and unmount. `setState` merged `likes` and kept `note`; its callback ran after `componentDidUpdate`, once the update was committed.
Preview
Console
Nothing logged yet.Expected console output
constructor
getDerivedStateFromProps Asha
render
componentDidMount
--- like ---
getDerivedStateFromProps Asha
shouldComponentUpdate
render
getSnapshotBeforeUpdate
componentDidUpdate likes 0 -> 1 snapshot
setState callback: likes = 1 note = hi
--- remove ---
componentWillUnmount`name` survives every update because `setState` merges. The object version adds 1 (both calls read the same `this.state`); the updater version adds 2. `PureLabel` renders once: its `text` prop never changed, so the shallow check skips it, while plain `Label` re-renders with its parent.
Preview
Console
Nothing logged yet.Expected console output
Form render: {"name":"Asha","count":0}
Label render
PureLabel render
right after setState, this.state.count = 0
Form render: {"name":"Asha","count":1}
Label render
Form render: {"name":"Asha","count":3}
Label renderThe script types like a user would, then switches user. The modern replacement is simpler: `<EditName key={user.id} user={user} />` with plain state, so React remounts it for each user.
Preview
Console
Nothing logged yet.Expected console output
draft after typing: Asha K
draft after switching user: RaviReact logs the caught error with the component name, then the boundary's `componentDidCatch` runs. Resetting the boundary's state renders the children again; this time they succeed.
Preview
Console
Nothing logged yet.Expected console output
Error: price service down
The above error occurred in the <Price> component.
React will try to recreate this component tree from scratch using the error boundary you provided, ErrorBoundary.
componentDidCatch: price service down
shown: Something went wrong: price service down
shown: Price: 499Same behaviour, a third of the code, and no forgotten `prevProps` check. On the update the class lines come first because `componentDidUpdate` runs in the layout step, while `useEffect` runs in the later passive step (Topic 26.1).
Preview
Console
Nothing logged yet.Expected console output
class connect general
hooks connect general
--- switch room ---
class disconnect general
class connect travel
hooks disconnect general
hooks connect travel
--- close ---
class disconnect travel
hooks disconnect travel| Class | Hooks equivalent | Notes |
|---|---|---|
| `constructor`, `state = {...}` | `useState` (one per value) or `useReducer` | Lazy init: `useState(() => expensive())` |
| `this.setState(partial)` (merges) | setter (replaces) | Spread to merge: `setForm((f) => ({ ...f, name }))` |
| `this.setState(updater)` | `setX((prev) => ...)` | Same batching rules |
| `this.setState(x, callback)` | `useEffect` on the new value, or do the work in the handler | |
| `componentDidMount` | `useEffect(fn, [])` | Plus a cleanup if it starts something |
| `componentDidUpdate(prevProps)` | `useEffect(fn, [deps])` | The deps replace the `prev !== this` checks |
| `componentWillUnmount` | the cleanup returned by the effect | |
| `shouldComponentUpdate`, `PureComponent` | `memo(Component)` | Or the React Compiler (Topic 16.5) |
| `static getDerivedStateFromProps` | compute during render, or reset with `key` | Topics 6.2, 6.4 |
| `getSnapshotBeforeUpdate` | `useLayoutEffect` + a ref | Read the DOM before paint |
| instance fields (`this.timer`) | `useRef` | Changing them doesn't re-render |
| `this.forceUpdate()` | a state setter, or `useSyncExternalStore` for outside data | Topic 10.3 |
| `contextType` / `<Consumer>` | `useContext` or `use(Context)` | Topic 9.2 |
| `getDerivedStateFromError`, `componentDidCatch` | **no hook**: keep a class boundary | Or `react-error-boundary` |
| `UNSAFE_componentWillMount/ReceiveProps/Update` | remove: move work to effects or render | Unsafe with concurrent rendering |Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Mutate this.state directly
In the lifecycle example, change like to this.state.likes++; console.log("likes is", this.state.likes); with no setState.
Myth vs fact
Myth
Class components are deprecated.
Fact
They are still fully supported in React 19. New code uses functions and hooks, and some new features (hooks themselves, Server Components) are function-only, but classes keep working.
Myth
componentDidMount is the same as useEffect(fn, []).
Fact
Close, but componentDidMount runs in the layout step before paint, like useLayoutEffect; useEffect runs after paint. For most work useEffect is the right replacement anyway.
Myth
setState replaces the whole state.
Fact
Class setState merges top-level keys. It's the hooks setter that replaces the value.
Myth
You must rewrite all classes before using hooks.
Fact
Classes and function components mix in one tree. Migrate when you touch a component, with tests.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
componentDidUpdaterunning in the layout step means asetStateinside it re-renders synchronously before paint, which is a common source of layout jank in old code; the hooks migration usually moves it touseEffector removes it entirely. - ▸
The
UNSAFE_methods exist because the render phase can be restarted (Topic 26.1):componentWillMountcan run several times for one mount, so side effects there duplicate. StrictMode warns about them. - ▸
React 19 removed string refs and
contextTypes/childContextTypes(legacy context), and ignorespropTypesanddefaultPropson function components. Old class code that relies on string refs (ref="input") must move tocreateRefor callback refs during an upgrade (Topic 23.6). - ▸
React Native, older libraries and many enterprise apps are full of classes. Being able to read
this.props.children, HOCs that wrap classes andcomponentDidUpdatecomparisons quickly is a real job skill, not just an interview topic.
Remember this
- 1
A class component is a JavaScript class (see
/javascript, classes) thatextends Componentfrom React and has arender()method returning JSX. Props arrive asthis.props. State is one object,this.state, set in theconstructor(after callingsuper(props)) or with a class fieldstate = { ... }. Methods that are passed as event handlers must keep the rightthis: write them as arrow-function class fields (handleClick = () => { ... }) or bind them in the constructor, otherwisethisisundefinedwhen React calls them. - 2
this.setState(partial)merges the object you pass into the existing state (only top-level keys), unlikeuseState's setter, which replaces the value. Updates are batched exactly like hooks updates, sothis.statedoesn't change until the next render: callingthis.setState({ count: this.state.count + 1 })twice adds one. The fix is the same as with hooks, an updater function:this.setState((prev) => ({ count: prev.count + 1 })). A second argument is a callback that runs after the update is committed, the class equivalent of reacting to a change in an effect. - 3
Lifecycle methods, in order. Mounting:
constructor→static getDerivedStateFromProps→render→ (DOM updated) →componentDidMount. Updating:getDerivedStateFromProps→shouldComponentUpdate→render→getSnapshotBeforeUpdate(reads the DOM just before it changes, e.g. scroll position) →componentDidUpdate(prevProps, prevState, snapshot). Unmounting:componentWillUnmount. The first group is the render phase and must be pure;getSnapshotBeforeUpdateruns in the commit's before-mutation step;componentDidMount/Updaterun in the layout step, at the same time asuseLayoutEffect(Topic 26.1). - 4
Performance controls.
shouldComponentUpdate(nextProps, nextState)returnsfalseto skip a render. **PureComponent** implements it for you with a shallow comparison of props and state, the class version ofmemo(Topic 16.3). **static getDerivedStateFromProps(props, state)** returns an object to merge into state before every render (ornull); its only good use is resetting some state when a prop like an id changes, and even that is usually better done with akey(Topic 6.4). The oldcomponentWillMount,componentWillReceivePropsandcomponentWillUpdateare unsafe in concurrent rendering and are only available with anUNSAFE_prefix. - 5
Error boundaries (Topic 14.5) are the one thing hooks can't do. A class with
static getDerivedStateFromError(error)(render phase: return state that switches to a fallback UI) and/orcomponentDidCatch(error, info)(commit phase: log or report it) catches errors thrown while rendering its children, in their lifecycle methods and constructors. It does not catch errors in event handlers, async code or itself. Most teams write one boundary class once, or use thereact-error-boundarypackage, and never write another class. - 6
Migrating to hooks is done one component at a time; classes and function components mix freely in one tree. The mapping:
this.state→ oneuseStateper independent value (oruseReducer);componentDidMount+componentDidUpdate+componentWillUnmountdoing one job → oneuseEffectwith the right dependencies and a cleanup; instance fields likethis.timer→useRef;shouldComponentUpdate/PureComponent→memo;getDerivedStateFromProps→ compute during render or reset withkey;getSnapshotBeforeUpdate→useLayoutEffectwith a ref;this.setState(x, callback)→ an effect that reacts to the new value, or do the work in the handler. Cover the component with behaviour tests first (Phase 17) so you know the migration changed nothing visible.
Explain it without notes
How does this.setState differ from a useState setter?
List the lifecycle methods in order for mount, update and unmount, and say which phase each runs in.
How do you migrate a class that subscribes in componentDidMount, re-subscribes in componentDidUpdate and unsubscribes in componentWillUnmount?
Why can't error boundaries be written with hooks, and what does each boundary method do?
Practice
Convert this class to a function component with hooks: a Toggle with state = { on: false }, a flip = () => this.setState((s) => ({ on: !s.on })) method, and componentDidUpdate that logs on is now true/false when on changes.
Write a class Ticker that counts up every second from mount and stops on unmount.
Trade-offs
- ↔
Migrating a working class costs time and risk; migrate when you are changing the component anyway, or when it blocks using a hook-based library.
- ↔
Classes group code by *when* it runs (lifecycle); hooks group it by *what* it does (one effect per concern), which is easier to reuse through custom hooks.
Done when you can
I can read a class component and point out its state, handlers and lifecycles.
I can explain
setStatemerging, batching, updaters and the callback.I can list the lifecycle methods in order and map each to hooks.
I can write an error boundary class.
I can migrate a class to hooks without changing its behaviour.