Read old React code, turn a class component into a function without changing what it does, see what React 19 removed, and plan the move for a big app.
Every part of this series so far used function components and hooks. But many apps you will work on are older than hooks. They were written with class components, the older way to write a component. Part 32 wrote one class, and Parts 40 and 41 showed the old ways to share code between classes.
This part starts the last stage of the series, Parts 56 to 58. These parts are about whole apps and teams, not one component. So they link back to earlier parts more than they teach new tools.
Here you will learn to read old React code. Then you’ll turn a class into a function, step by step, without changing what it does. Then we plan the same move for a big app, and go through the upgrade from React 18 to React 19.
Try this first
This Profile is a class component. It asks a server for a user when it first appears. There’s no real server here, so fetchUser pretends to be one. Read the code, but don’t press Run yet.
import { Component, useState } from 'react'
type User = { id: number; name: string }
// A pretend server. It answers after half a second.
function fetchUser(id: number): Promise<User> {
console.log('load user ' + id)
return new Promise(resolve => {
setTimeout(() => resolve({ id, name: 'User ' + id }), 500)
})
}
type Props = { userId: number }
type State = { user: User | null }
class Profile extends Component<Props, State> {
state: State = { user: null }
componentDidMount() {
fetchUser(this.props.userId).then(user => this.setState({ user }))
}
render() {
const user = this.state.user
return <p>Showing: {user ? user.name : 'Loading...'}</p>
}
}
export default function App() {
const [userId, setUserId] = useState(1)
return (
<div>
<button onClick={() => setUserId(2)}>User 2</button>
<p>You asked for user {userId}.</p>
<Profile userId={userId} />
</div>
)
}
load user 1
load user 1
Make a guess. Press “User 2” after the first user shows up. What will the last line say?
Now press Run, wait, and press “User 2”.
The page says “You asked for user 2.” But under it, it still says “Showing: User 1”. Nothing new was loaded. The Console shows load user 1 twice, and never load user 2.
componentDidMount runs once, when the component is first added to the page. That is all this class asked for. When the userId prop changed, nothing told the class to load again. React’s Component page warns about this. It says that with componentDidMount, “you usually need to implement other lifecycle methods to avoid bugs”. Hooks group the code in a way that makes this bug harder to write, as you’ll see below.
Why does load user 1 print twice? That is Strict Mode, which does the same test for classes as it does for Effects (Part 11). In development, React adds the class, removes it as a test, and adds it again. So componentDidMount runs twice.
What old React code looks like
Legacy code is old code that a team still has to keep working. Here is what you will meet in legacy React code, and where this series explains it.
- Class components.
class Profile extends Component, withthis.props,this.state,this.setStateand arender()method. Part 32 explains each piece. - Lifecycle methods. These are class methods that React calls at certain moments. You’ll see three most often.
componentDidMountruns just after the component is added to the page.componentDidUpdateruns just after it updates.componentWillUnmountruns just before it’s removed. A lifecycle is the life of a component, from added to removed. - Higher-order components and render props. Functions that wrap a component, and props that are functions. Parts 41 and 40 cover both. They still work in React 19.
forwardRef. Needed before React 19 to pass arefto a function component. Part 14 shows it.propTypes. Checks on the type of each prop, made while the app runs, in development only. TypeScript does this job now, before the app runs.- Old context, string refs,
findDOMNodeandReactDOM.render. These four are gone in React 19. You’ll see what replaces each one below.
React’s docs still explain classes. Their Component page says: “Class components are still supported by React, but we don’t recommend using them in new code.”
What React 19.3 does with each
React 19 removed some old APIs. An API is a part of a library that your code calls, like a function or a method. The React 19 upgrade guide lists what was removed. We tried each old API in React 19.3, in development, and wrote down what happened. There are four kinds of result.
It works, with no message
| Old code | What we saw |
|---|---|
A class component with this.setState |
works |
PureComponent, createRef, forwardRef |
works |
static contextType (the newer way for a class to read context) |
works |
defaultProps on a class |
works |
| Higher-order components and render props | work, as Parts 40 and 41 showed |
It works, but React warns
| Old code | What we saw |
|---|---|
componentWillMount, componentWillReceiveProps, componentWillUpdate |
the method still runs. React prints a warning (console.warn) like componentWillMount has been renamed, and is not recommended for use. |
The new names, like UNSAFE_componentWillMount |
the method still runs. Inside <StrictMode>, React prints an error message (console.error): Using UNSAFE_componentWillMount in strict mode is not recommended and may indicate bugs in your code. |
These three methods run before React renders. With newer features like Suspense (Part 31), React may call them and then throw that render away. That’s why React gave them new names, with UNSAFE_ at the front. The Component page lists what to use instead of each one.
It’s removed, and you get an error
| Old code | What we saw | Use instead |
|---|---|---|
A string ref, <input ref="input" /> |
React stops with Expected ref to be a function, an object returned by React.createRef(), or undefined/null. The whole app is gone. |
useRef, or createRef or a ref callback in a class |
ReactDOM.findDOMNode(this) |
findDOMNode is undefined, so calling it throws a TypeError |
a ref on the tag you want |
ReactDOM.render |
undefined, so calling it throws a TypeError |
createRoot(container).render(<App />) |
ReactDOM.hydrate |
undefined |
hydrateRoot(container, <App />) |
unmountComponentAtNode |
undefined |
root.unmount() |
createFactory |
undefined |
JSX |
A “module pattern factory”: a function that returns an object with a render method |
React stops with Objects are not valid as a React child |
a normal function component |
Old context: contextTypes and getChildContext |
this.context is an empty object. React prints errors, including Child uses the legacy contextTypes API which was removed in React 19. |
createContext, read with static contextType or useContext |
act from react-dom/test-utils |
still works, with an error message telling you to import act from react. It’s the only thing left in react-dom/test-utils. |
import { act } from 'react' |
It’s removed, and nothing tells you
| Old code | What we saw | Use instead |
|---|---|---|
propTypes |
ignored. Our check function never ran, and React printed nothing. | TypeScript |
defaultProps on a function component |
ignored. The prop stayed undefined, and React printed nothing. |
a default value: function Heading({ text = 'Hello' }) |
The upgrade guide says it plainly: propTypes “will be silently ignored”. This last group is the dangerous one. An error makes you look. A warning makes you look. But a default value that quietly stops working only shows up as a wrong page. A search of the code, and tests, find it.
A class, piece by piece
Every piece of a class has a modern partner. This table is the map for the rest of this part.
| In a class | In a function | Where the series explains it |
|---|---|---|
this.props.roomId |
the roomId prop |
Part 3 |
state = {...} and this.setState(...) |
useState |
Part 4 |
methods, and this.handleClick.bind(this) |
plain functions inside the component | Part 6 |
componentDidMount, componentDidUpdate, componentWillUnmount |
one useEffect, with a cleanup and dependencies |
Parts 11 and 12 |
getDerivedStateFromProps |
work it out while rendering, or reset with a key |
Parts 11 and 18 |
shouldComponentUpdate, PureComponent |
memo |
Part 20 |
createRef, this.input |
useRef |
Part 14 |
static contextType, this.context |
useContext or use |
Part 23 |
forceUpdate for outside data |
useSyncExternalStore |
Part 25 |
getDerivedStateFromError, componentDidCatch |
keep the class | Part 32 |
The biggest change is the lifecycle methods. Let’s start there.
Three lifecycle methods become one Effect
Here is a chat room as a class. It’s the same pretend chat server as Part 12. It connects when it appears. It moves to a new room when roomId changes. It disconnects when it’s removed. You can also type a message and send it.
import { Component, useState } from 'react'
// A pretend chat server. It only prints what it does.
function createConnection(roomId: string) {
return {
connect() {
console.log('connect to ' + roomId)
},
disconnect() {
console.log('disconnect from ' + roomId)
},
}
}
type Connection = ReturnType<typeof createConnection>
type Props = { roomId: string }
type State = { draft: string; sent: number }
class ChatRoom extends Component<Props, State> {
state: State = { draft: '', sent: 0 }
connection: Connection | null = null
componentDidMount() {
this.connection = createConnection(this.props.roomId)
this.connection.connect()
}
componentDidUpdate(prevProps: Props) {
if (prevProps.roomId !== this.props.roomId) {
this.connection?.disconnect()
this.connection = createConnection(this.props.roomId)
this.connection.connect()
}
}
componentWillUnmount() {
this.connection?.disconnect()
}
handleSend = () => {
this.setState(state => ({ draft: '', sent: state.sent + 1 }))
}
render() {
return (
<div>
<p>You are in the {this.props.roomId} room. Sent: {this.state.sent}</p>
<input
aria-label="Message"
value={this.state.draft}
onChange={e => this.setState({ draft: e.target.value })}
/>
<button onClick={this.handleSend}>Send</button>
</div>
)
}
}
export default function App() {
const [roomId, setRoomId] = useState('music')
const [show, setShow] = useState(true)
return (
<div>
<button onClick={() => setRoomId('games')}>Go to games</button>
<button onClick={() => setShow(false)}>Leave</button>
{show && <ChatRoom roomId={roomId} />}
</div>
)
}
connect to music
disconnect from music
connect to music
disconnect from music
connect to games
disconnect from games
Run it. Type a message and press Send. Then press “Go to games”, then “Leave”, and read the Console.
The first three lines are Strict Mode’s test again. After that, “Go to games” disconnects from music and connects to games. “Leave” disconnects from games. At the end, no connection is open.
Look at how the class does its one job, staying connected to the right room. It is spread over three methods. componentDidMount connects. componentDidUpdate checks whether the room changed, then disconnects and connects. componentWillUnmount disconnects. React’s old page about hooks names this problem. In a class, the code for one job “gets split apart”.
An Effect keeps the whole job in one place:
useEffect(() => {
const connection = createConnection(roomId)
connection.connect()
return () => connection.disconnect()
}, [roomId])
React’s Component page says that for many cases, the three methods together are “equivalent to calling useEffect in function components”. Here is how the calls line up.
A class’s three lifecycle methods and one Effect make the same calls. Press play, or step through it.
- The chat room is added. The class runs
componentDidMount. The Effect runs its setup. Both printconnect to music. roomIdchanges to games. The class’scomponentDidUpdatedisconnects, then connects. The Effect runs its cleanup with the old room, then its setup with the new one. Both printdisconnect from music, thenconnect to games.- The chat room is removed. The class runs
componentWillUnmount. The Effect runs its cleanup. Both printdisconnect from games. - In development, Strict Mode adds a test when the chat room first appears:
connect to music,disconnect from music,connect to music. For the class, that iscomponentDidMount,componentWillUnmount,componentDidMount. For the Effect, it is setup, cleanup, setup.
The if inside componentDidUpdate and the [roomId] array do the same job. They decide when to connect again. In the class, you write that check by hand. In the Effect, you list the values, and React compares them for you (Part 11).
The bug: forgetting to compare
Old code often forgets that if. React’s old docs show the right way, with a comment in their example: “don’t forget to compare props”.
Press Edit on the class above. Delete the line if (prevProps.roomId !== this.props.roomId) { and its closing }. Press Run, then type one letter in the message box. The Console gets two new lines:
disconnect from music
connect to music
componentDidUpdate runs after every update, for any reason, unless shouldComponentUpdate returns false. Typing changes the state, so the class updates. Then it connects again to the same room. Every letter you type does it again. With a real server, that is a new network connection for every letter.
The Effect can’t forget the check in the same way. It connects again only when a value in [roomId] changes. If you leave a value out, the linter warns you, as you’ll see under Common mistakes.
An everyday example
A class is like three alarm clocks: “when I arrive, connect”, “when something changes, check the room”, “when I leave, disconnect”. You must set all three, and keep them in agreement.
An Effect is one rule: “while I’m in room X, stay connected to room X”. React works out when to connect and disconnect.
The exact version
The rule is a good picture, but the Effect is not the same as the class in every detail.
- An Effect usually runs after the browser shows the new page.
componentDidMountandcomponentDidUpdaterun before that. For the rare code that must run before the page shows, the docs sayuseLayoutEffect“is a closer match” (Part 11). - The class reads
this.propswhen each method runs, so it always sees the newest props. The Effect’s cleanup sees the values from its own render. That’s why the cleanup says “music” and not “games” (Part 12). - If the class does two jobs, it mixes them in the same three methods. In a function, write one Effect for each job. The
Componentpage gives the same advice: split them into “multiple independent Effects”.
The other methods
getDerivedStateFromProps: work it out, or use a key
getDerivedStateFromProps runs before every render. It returns some new state, or null for no change. Here it puts the email box back to the user’s own email when the user changes:
import { Component } from 'react'
type Props = { userId: number; defaultEmail: string }
type State = { email: string; prevUserId: number }
class EmailForm extends Component<Props, State> {
state: State = { email: this.props.defaultEmail, prevUserId: this.props.userId }
static getDerivedStateFromProps(props: Props, state: State) {
if (props.userId !== state.prevUserId) {
return { email: props.defaultEmail, prevUserId: props.userId }
}
return null
}
render() {
return (
<input
aria-label="Email"
value={this.state.email}
onChange={e => this.setState({ email: e.target.value })}
/>
)
}
}
export { EmailForm }
That’s a lot of work. The class has to keep the old userId in its state, just to see that it changed.
Most of the time, you don’t need any of it. There are two common cases.
- The state is only worked out from props. Then don’t keep it in state. Work it out while rendering, as Part 11 showed.
- You want a fresh start when a prop changes. Then give the component a
key. A new key makes a new component, with new state (Part 18).
The email form is the second case:
import { useState } from 'react'
const users = [
{ id: 1, email: 'ana@example.com' },
{ id: 2, email: 'ben@example.com' },
]
function EmailForm({ defaultEmail }: { defaultEmail: string }) {
const [email, setEmail] = useState(defaultEmail)
return <input aria-label="Email" value={email} onChange={e => setEmail(e.target.value)} />
}
export default function App() {
const [userId, setUserId] = useState(1)
const user = users.find(u => u.id === userId)!
return (
<div>
<button onClick={() => setUserId(2)}>Ben</button>
<EmailForm key={user.id} defaultEmail={user.email} />
</div>
)
}
Run it. Change the email, then press “Ben”. The box shows Ben’s email. We ran the class version with the same steps, and the box showed the same three values.
user.id is the key. When it changes, React removes the old EmailForm and makes a new one, and useState starts again from defaultEmail. The ! after find(...) tells TypeScript that a user will always be found.
shouldComponentUpdate and PureComponent: memo
PureComponent skips a re-render when the props and state are the same as last time. For a function component, memo does the same for props (Part 20):
import { PureComponent, memo } from 'react'
type Props = { name: string }
class GreetingOld extends PureComponent<Props> {
render() {
return <p>Hello, {this.props.name}!</p>
}
}
const Greeting = memo(function Greeting({ name }: Props) {
return <p>Hello, {name}!</p>
})
export { GreetingOld, Greeting }
We checked, with Strict Mode off. A parent re-rendered 3 times and passed the same name. The PureComponent child rendered 0 more times, and so did the memo child. A plain class child rendered 3 more times.
A class can also write its own shouldComponentUpdate. It returns true when the component should render. memo‘s own compare function (Part 20) works the other way round. It returns true when the props are equal, so React skips the render. Flip the answer when you move the code across.
PureComponent also compares the state. memo can’t, because it only sees props. You don’t need it to: when you set a useState value to the same value, React skips the re-render (Part 4).
With the React Compiler (Part 22), React’s docs say “you typically don’t need React.memo anymore”.
Refs: createRef becomes useRef
import { Component, createRef, useEffect, useRef } from 'react'
class SearchBoxOld extends Component {
input = createRef<HTMLInputElement>()
componentDidMount() {
this.input.current?.focus()
}
render() {
return <input ref={this.input} aria-label="Search" />
}
}
function SearchBox() {
const input = useRef<HTMLInputElement>(null)
useEffect(() => {
input.current?.focus()
}, [])
return <input ref={input} aria-label="Search" />
}
export { SearchBoxOld, SearchBox }
Here the empty array [] is right. The Effect reads no props and no state, so there’s nothing to update. We checked that both put the focus in the box.
A string ref, ref="input" with this.refs.input, becomes the same useRef.
One more case breaks when you move a class. A parent can put a ref on a class component. Then the ref holds the class object itself, so the parent can call its methods, like ref.current.focusBox(). A function component has no such object. We tried it: after the move, the ref held null, and calling focusBox() threw a TypeError. React printed no warning. If parents call methods on your class, give the function those methods with useImperativeHandle (Part 14).
Context: this.context becomes useContext
A class reads one context with static contextType = ThemeContext, then uses this.context. A function calls useContext(ThemeContext) or use(ThemeContext) instead (Part 23). A function can read as many contexts as it needs. A class can read only one this way.
Old context, with contextTypes, must move to createContext first. React 19 no longer passes it at all.
Error boundaries stay classes
An error boundary needs static getDerivedStateFromError and componentDidCatch, and only classes have them. React’s docs say there is “no direct equivalent” for componentDidCatch in function components “yet”. So keep your error boundary class, as Part 32 did. The react-error-boundary package is the other choice.
getSnapshotBeforeUpdate has no hook either. It reads something from the page, like a scroll position, just before React changes it. The docs call it “very uncommon”.
Step by step: one class becomes a function
Let’s move the ChatRoom class above to a function, without changing what it does. This is the order to follow for any class.
Step 1: write a test for what it does now
Before you change anything, write a test that checks what the component does today. Not what it should do, what it does. This is called a characterization test: a test that describes what old code does right now. It doesn’t say whether that is right. It only tells you when something changes. If the test still passes after your change, you didn’t change the behavior by mistake.
Here is one for the chat room, written with the tools from Part 49. Save it as src/App.test.tsx in the Part 10 project, set up as in Part 49. It expects the chat room app in src/App.tsx.
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { expect, test, vi } from 'vitest'
import App from './App'
test('the chat room works the way it does today', async () => {
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
const user = userEvent.setup()
render(<App />)
await user.type(screen.getByLabelText('Message'), 'hi')
await user.click(screen.getByRole('button', { name: 'Send' }))
expect(screen.getByText(/Sent: 1/)).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Go to games' }))
expect(screen.getByText(/games room/)).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Leave' }))
expect(log.mock.calls.map(call => call[0])).toEqual([
'connect to music',
'disconnect from music',
'connect to games',
'disconnect from games',
])
})
vi.spyOn(console, 'log') watches every call to console.log. mockImplementation(() => {}) stops the lines from printing. At the end, the test checks the list of lines.
The test only uses what a user can see and what the app prints. It never reads this.state. That matters here. A test that reads a class’s state can’t run on a function, which has no this.state. Part 49 explained why tests should skip details the user can’t see.
React Testing Library doesn’t turn on Strict Mode, and this test assumes that default. Part 49 also showed how to turn it on for every test, with configure({ reactStrictMode: true }). We tried that. Then both versions printed Strict Mode’s test lines too, so the list must be these six:
connect to music
disconnect from music
connect to music
disconnect from music
connect to games
disconnect from games
We ran this test on the class. It passed.
Step 2: check the class first
React’s Component page gives two checks before you move lifecycle methods.
- Does
componentWillUnmountundo whatcomponentDidMountdid? Here it does: it disconnects. - Does
componentDidUpdatehandle a change to every prop and state value thatcomponentDidMountuses? HerecomponentDidMountuses onlythis.props.roomId, andcomponentDidUpdatechecks it.
If either check fails, fix the class first, and run the test again. Otherwise you will copy the bug into the function.
Step 3: move the state
Each part of this.state becomes its own useState: draft and sent.
Watch one difference. this.setState({ draft: '' }) merges the new value into the state object, as the Component page says. The other values stay. A useState set function replaces the whole value (Part 5). With one useState for each value, there’s nothing to merge.
Step 4: move the methods
handleSend becomes a plain function inside the component. There’s no this, so there’s no bind, and no arrow function as a class field.
Step 5: move the lifecycle methods into one Effect
The three methods become the Effect you saw above, with [roomId].
Step 6: move render
The body of render() becomes what the function returns. this.props.roomId becomes roomId, and this.state.sent becomes sent.
Here is the result. App and createConnection are the same as before.
import { useEffect, useState } from 'react'
// A pretend chat server. It only prints what it does.
function createConnection(roomId: string) {
return {
connect() {
console.log('connect to ' + roomId)
},
disconnect() {
console.log('disconnect from ' + roomId)
},
}
}
function ChatRoom({ roomId }: { roomId: string }) {
const [draft, setDraft] = useState('')
const [sent, setSent] = useState(0)
useEffect(() => {
const connection = createConnection(roomId)
connection.connect()
return () => connection.disconnect()
}, [roomId])
function handleSend() {
setDraft('')
setSent(s => s + 1)
}
return (
<div>
<p>You are in the {roomId} room. Sent: {sent}</p>
<input aria-label="Message" value={draft} onChange={e => setDraft(e.target.value)} />
<button onClick={handleSend}>Send</button>
</div>
)
}
export default function App() {
const [roomId, setRoomId] = useState('music')
const [show, setShow] = useState(true)
return (
<div>
<button onClick={() => setRoomId('games')}>Go to games</button>
<button onClick={() => setShow(false)}>Leave</button>
{show && <ChatRoom roomId={roomId} />}
</div>
)
}
connect to music
disconnect from music
connect to music
disconnect from music
connect to games
disconnect from games
Run it and do the same steps as before. The page and the Console show the same things, in the same order. Our checks for this page run both versions with the same clicks. Both give the same text and the same six lines.
Step 7: run the test again
We saved the function version as src/App.tsx and ran the same test. It passed. Now you can delete the class.
The test also catches the classic mistake. We changed [roomId] to [] and ran it again. It failed, and Vitest printed this:
- Expected
+ Received
[
"connect to music",
"disconnect from music",
- "connect to games",
- "disconnect from games",
]
A line with - was expected but never printed. The page said “games room”, but the app never connected to games.
Moving a big app
One class is easy. A real app may have hundreds. Here is a plan that works with a team that keeps adding features at the same time.
Don’t rewrite everything at once
A big-bang rewrite means you stop, write the whole app again, and switch over on one day. It sounds clean, but it often goes wrong. Users wait months for new features. And the new app has to copy every old detail, even the ones nobody wrote down.
When hooks arrived, React’s team said the same about classes: “We recommend avoiding any “big rewrites””. They also said hooks “work side-by-side with existing code”. We checked: a class can render a function component that uses hooks, and a function can render a class.
Grow the new code around the old
Martin Fowler wrote about a plant called the strangler fig. It grows around an old tree, little by little, until it no longer needs the tree. In software, a strangler fig plan means new code grows next to the old code. Each step moves one piece of behavior from the old code to the new. Fowler writes that the name is now often used for this slow, step by step way of replacing old software.
For React, that means:
- Write every new component as a function with hooks, from today. When hooks were new, React’s team said to try them first in “new and non-critical components”.
- Move old components one at a time, each in its own small change, with its own tests.
- Ship each change. The app works after every step.
Start at the leaves
Picture the app as a tree of components (Part 18). A leaf is a component with no components of its own inside it, only HTML tags. Start there.
A leaf is small, so its test is small. Nothing below it can break. And because a class can render a function, the class above it doesn’t have to change yet. Then move up the tree, one parent at a time.
Tests first
Write characterization tests before each move, as in Step 1. Test what the user sees and what the app sends out, not the class’s state. Then the same test runs on the old version and the new one.
If a part of the app has no tests at all, write tests for the screens people use most. Start there, before any code moves.
Codemods: let a tool make the simple changes
A codemod is a program that changes your code for you. It finds a pattern, like ReactDOM.render(...), and rewrites it. The React 19 upgrade guide gives one command that runs all its React 19 codemods:
npx codemod@latest react/19/migration-recipe
The guide says this runs five codemods. They change ReactDOM.render to createRoot, and string refs to ref callbacks. They move the act import to react, and change useFormState to useActionState. And they turn propTypes into TypeScript types. The guide adds: “This does not include the TypeScript changes.” Those have their own command, npx types-react-codemod@latest preset-19 ./path-to-app.
We ran the recipe on a small old app in a test folder. It changed ReactDOM.render to createRoot. It changed the string ref to a ref callback. It changed propTypes to a TypeScript interface. But our file was App.jsx, plain JavaScript, and a .jsx file can’t hold TypeScript. So vite build failed at the line interface SearchBoxProps {. The old act import, in a test file named App.test.jsx, was not changed at all. The summary said Modified 3, but git status showed 2 changed files.
The new ref callback writes into this.refs, which React’s Legacy APIs page lists as removed. Even so, it worked in React 19.3. The box got the focus, and React printed nothing.
So run a codemod on a clean copy of your code, in version control. Read every change, then run your tests and your build.
None of React’s codemods turns a class with state and lifecycle methods into a function with hooks. The react-codemod list has pure-component, which converts only classes that have nothing but a render method and props. The rest is the step-by-step work above.
Leave some classes alone
You don’t have to move every class. Classes still work in React 19. A class that works, has tests and rarely changes can wait. Move a class when you need to change it anyway. Error boundaries stay classes for now.
Upgrading from React 18 to React 19
Moving classes to functions and upgrading React are two different jobs. Do them one at a time. The upgrade guide gives the steps. Here they are as a list.
- Upgrade to React 18.3 first. The guide says 18.3 is “identical to 18.2 but adds warnings for deprecated APIs”. Deprecated means “still works, but planned for removal”. Run the app and fix every new warning. We tried the old APIs in React 18.3.1, and it warned about almost all of them.
propTypeswas the one that stayed quiet: the checks still ran, and React printed only when a check failed. So also search your code forpropTypes. - Use
createRoot.ReactDOM.renderis gone in React 19. A Vite project already usescreateRoot, insrc/main.tsx(Part 10). - Use the new JSX transform. This is the JSX setting that writes
_jsxcalls (Part 1). Without it, the guide says, React 19 warns:Your app (or one of its dependencies) is using an outdated JSX transform.Vite already uses the new one. - Remove the removed APIs. The tables above list them. Fix string refs, old context,
findDOMNode,ReactDOM.render,hydrateandunmountComponentAtNode. Then fixcreateFactory, module pattern factories,react-dom/test-utils,propTypes, anddefaultPropson functions. - Install React 19. The guide’s command is
npm install --save-exact react@^19.0.0 react-dom@^19.0.0. With TypeScript, alsonpm install --save-exact @types/react@^19.0.0 @types/react-dom@^19.0.0. - Run the codemods, then read every change, as above.
- Turn on Strict Mode. Its page says: “Your components will be checked for usage of deprecated APIs.” Strict Mode also runs setup and cleanup an extra time. So a missing cleanup shows up, in classes and in Effects (Part 11).
- Run your tests, and watch the Console. Remember the quiet group:
propTypesand functiondefaultPropsgive no message at all.
Common mistakes
Copying componentDidMount into an Effect with []
componentDidMount runs once, so it’s easy to copy it into useEffect(..., []):
useEffect(() => {
const connection = createConnection(roomId)
connection.connect()
return () => connection.disconnect()
}, [])
This copies the bug from “Try this first”. Press Edit on the function ChatRoom above, change [roomId] to [], and run the same steps. We did. The Console showed only Strict Mode’s three lines at the start. “Go to games” printed nothing. “Leave” printed disconnect from music. The page said “games room” the whole time the app was connected to music.
The linter catches it. We ran Oxlint, in the Part 10 project, on that code. Here are the first and last lines of its warning:
! react-hooks(exhaustive-deps): React Hook useEffect has a missing dependency: 'roomId'
help: Either include it or remove the dependency array.
The fix: list every value the Effect uses, [roomId]. componentDidMount plus componentDidUpdate is one Effect with its dependencies, not an Effect with [].
Leaving out the array
With no array at all, an Effect runs after every render. That is the “forgot to compare” bug again. We removed [roomId] from the function ChatRoom and typed one letter. The Console showed disconnect from music and connect to music, the same as the class without its if. Oxlint said nothing about this one, so look for it yourself.
Merging state like setState did
If you keep the class’s whole state in one useState, don’t set it the way the class did:
import { useState } from 'react'
export default function App() {
const [form, setForm] = useState({ draft: '', sent: 0 })
return (
<div>
<p>Sent: {form.sent}</p>
<button onClick={() => setForm({ draft: '' })}>Clear</button>
</div>
)
}
TypeScript stops it: “Property ‘sent’ is missing”. The playground doesn’t check types, so press Run and click “Clear”. The text becomes “Sent: “. sent is gone, because the set function replaced the whole object. The fix: setForm({ ...form, draft: '' }), or one useState for each value.
Moving everything at once
Changing hundreds of components in one change means one huge review, and no way to tell which change broke what. Move one component, test it, ship it, and then move the next.
Converting the error boundary
There’s no hook for componentDidCatch. Keep the class.
Practice
- In “Try this first”, add a
componentDidUpdatetoProfilethat loads the user again whenuserIdchanges. Run it, then press “User 2”. What does the Console show? - Now turn
Profileinto a function component withuseStateanduseEffect. Run it and press “User 2”. What does the Console show? - In the function
ChatRoom, delete the dependency array[roomId]completely. Run it and type one letter. What new lines appear? - Turn this class into a function. In development, how many
setIntervaltimers are left running after Run?
import { Component } from 'react'
type State = { seconds: number }
class Timer extends Component<object, State> {
state: State = { seconds: 0 }
intervalId = 0
componentDidMount() {
console.log('start')
this.intervalId = window.setInterval(() => {
this.setState(state => ({ seconds: state.seconds + 1 }))
}, 1000)
}
componentWillUnmount() {
console.log('stop')
window.clearInterval(this.intervalId)
}
render() {
return <p>Seconds: {this.state.seconds}</p>
}
}
export default function App() {
return <Timer />
}
start
stop
start
Answers
1. For example, add this method to Profile:
componentDidUpdate(prevProps: Props) {
if (prevProps.userId !== this.props.userId) {
fetchUser(this.props.userId).then(user => this.setState({ user }))
}
}
The Console shows load user 1 twice, from Strict Mode’s test. Then it shows load user 2 once. The page shows “Showing: User 2”.
2. For example, change the import to useEffect, useState, and put this in place of the class:
function Profile({ userId }: Props) {
const [user, setUser] = useState<User | null>(null)
useEffect(() => {
fetchUser(userId).then(result => setUser(result))
}, [userId])
return <p>Showing: {user ? user.name : 'Loading...'}</p>
}
The Console is the same as in answer 1: load user 1 twice, then load user 2 once. To guard against a slow old answer, add the ignore flag from Part 12.
3. Two new lines for each letter: disconnect from music, then connect to music. With no array, the Effect runs after every render.
4. One. The Console shows start, stop, start. Strict Mode’s test adds the timer, removes it and adds it again. The cleanup clears the first timer, so one is left. The page counts one second at a time. One answer, with the import changed to useEffect, useState:
function Timer() {
const [seconds, setSeconds] = useState(0)
useEffect(() => {
console.log('start')
const id = setInterval(() => setSeconds(s => s + 1), 1000)
return () => {
console.log('stop')
clearInterval(id)
}
}, [])
return <p>Seconds: {seconds}</p>
}
[] is right here, because the Effect uses no props or state. The function prints the same three lines.
Interview questions
Try to answer each one out loud before you open the answer.
How do componentDidMount, componentDidUpdate and componentWillUnmount map to hooks?
Together they become one useEffect. The setup does what componentDidMount did. The cleanup does what componentWillUnmount did. The dependency array replaces the if that compared prevProps in componentDidUpdate. When a listed value changes, React runs the cleanup with the old values. Then it runs the setup with the new ones.
A strong answer adds that the code is split in a new way. A class splits code by moment. So one job is spread over three methods, and one method can hold several jobs. With hooks, you write one Effect for each job. It also notes that Effects usually run after the browser shows the page. useLayoutEffect is closer when code must run before that.
What goes wrong if you copy componentDidMount into useEffect(fn, [])?
The Effect runs only when the component is added. If it uses a prop, like roomId, it never sees the new value. The component shows one thing and is connected to another. The class version usually also had a componentDidUpdate for that, and the copy lost it.
The fix is to list every value the Effect uses. The hooks linter, in ESLint or Oxlint, warns about the missing value. A strong answer adds when [] is right. The Effect must use no props, no state and no other values declared in the component.
How would you move a large class-based app to modern React?
Not with a big-bang rewrite. Write all new components as functions from today. Move old ones one at a time, starting with leaf components, each in its own small change. Before each move, write characterization tests that check what the user sees. Then the same test runs on the old code and the new code. Use codemods for the simple, mechanical changes, and read what they did.
A strong answer names the strangler fig idea. New code grows around the old, and the app works after every step. It keeps error boundaries as classes. It leaves stable, well-tested classes for later. And it upgrades React and moves classes as two separate jobs.
What did React 19 remove, and how do you find it in your code?
ReactDOM.render, hydrate, unmountComponentAtNode, findDOMNode, string refs, old context (contextTypes), createFactory, module pattern factories, propTypes, and defaultProps on function components. Classes keep defaultProps. From react-dom/test-utils, only act is left, and it moved to react. react-test-renderer/shallow is gone too.
To find them, upgrade to React 18.3 first, which warns about most of them. Then run the app in Strict Mode. Search the code for the names. A strong answer points out that propTypes and function defaultProps are silently ignored in React 19. No error and no warning shows up, so only tests or a search will find them.
What is a characterization test, and why write one before a migration?
A test that records what the code does now, not what it should do. You run the old code, see what it shows or prints, and write a test that expects exactly that. Then you change the code. If the test still passes, the behavior didn’t change.
A strong answer adds that the test must check only what can be seen from outside. That means the page, and the calls the code makes. A test that reads the class object, like this.state or its methods, can’t survive the move to a function.
What replaces getDerivedStateFromProps?
Usually nothing like it. If the state is only worked out from props, work it out while rendering. Don’t keep it in state. To reset state when a prop changes, put a key on the component. A new key makes a new component.
A strong answer adds the rare case where you must change state when a prop changes. React’s docs say the class method is “equivalent to calling the set function from useState during rendering”.
Which class features have no hook?
static getDerivedStateFromError and componentDidCatch, which make an error boundary, and getSnapshotBeforeUpdate. For the first two, keep one boundary class, or use the react-error-boundary package. React’s docs call the third “very uncommon”.
Sources
- React 19 Upgrade Guide, react.dev: React 18.3, the new JSX transform warning, the install commands, the codemod recipe and its five codemods, the TypeScript codemod, and each removed API, including “will be silently ignored”.
- Component, react.dev: each lifecycle method, the two checks before moving them, “equivalent to calling useEffect in function components”, splitting jobs into “multiple independent Effects”,
getDerivedStateFromProps,shouldComponentUpdate, and the methods with no hook. - Legacy React APIs, PureComponent and memo, react.dev: what is legacy, what was removed, and “Class components are still supported by React”.
- StrictMode, react.dev: “Your components will be checked for usage of deprecated APIs.”
- React.Component, React’s old docs: “don’t forget to compare props”.
- Typechecking With PropTypes, React’s old docs:
propTypesis only checked in development. - Introducing Hooks, React’s old docs: code that “gets split apart”, “work side-by-side with existing code”, avoiding “big rewrites”, and starting with “new and non-critical components”.
- react-codemod, GitHub: what each codemod does, including
pure-component. - Strangler Fig, Martin Fowler, 2024: the strangler fig, the name, and moving behavior piece by piece.
- Characterization test, Wikipedia: tests that describe what old code does now, not what it should do.
- The results for each old API in React 19.3.0 and 18.3.1, the logs, the render counts and the test runs come from running React 19.3.0 for this post. The codemod run used
codemod1.18.3 and Vite 8.3.3. The lint message comes from Oxlint 1.87.0 in the Part 10 project. The tests ran in Vitest 5.0.3 with React Testing Library 16.3.3. - This part follows the Migrating Legacy React to Modern kata in react-katas.