Class components
In this chapter, you learn about React class components. Function components are the mainstream in modern React development, but you still need this knowledge to understand existing codebases and to implement Error Boundaries.
What you learn in this chapter
- The basic syntax of class components
- The correspondence between lifecycle methods and Hooks
- Implementing an Error Boundary
- How to migrate from a class to a function component
This chapter uses the project created in Chapter 3. Start the dev server with npm run dev and learn while writing code.
Why learn class components
- Understanding legacy code: existing projects may contain class components
- Error Boundary: can only be implemented with class components
- Understanding the lifecycle: you can deeply understand how React works
For new development, use function components + Hooks. Class components are not recommended unless you have a special reason.
Class component basics
Basic syntax
import { Component } from 'react'
type GreetingProps = {
name: string
}
class Greeting extends Component<GreetingProps> {
render() {
return <h1>Hello, {this.props.name}!</h1>
}
}
// Usage
<Greeting name="React" />
Comparison with function components
// Class component
class ClassCounter extends Component<{}, { count: number }> {
state = { count: 0 }
increment = () => {
this.setState({ count: this.state.count + 1 })
}
render() {
return (
<div>
<p>{this.state.count}</p>
<button onClick={this.increment}>+1</button>
</div>
)
}
}
// Function component (recommended)
function FunctionCounter() {
const [count, setCount] = useState(0)
return (
<div>
<p>{count}</p>
<button onClick={() => setCount(c => c + 1)}>+1</button>
</div>
)
}
State
State management in a class component.
Initializing state
class Counter extends Component<{}, { count: number; name: string }> {
// Approach 1: initialize with a class field (recommended)
state = {
count: 0,
name: ''
}
// Approach 2: initialize in the constructor
// constructor(props: {}) {
// super(props)
// this.state = { count: 0, name: '' }
// }
render() {
return <p>{this.state.count}</p>
}
}
setState
class Counter extends Component<{}, { count: number }> {
state = { count: 0 }
// Passing an object
increment = () => {
this.setState({ count: this.state.count + 1 })
}
// Passing a function (recommended when it depends on the previous state)
incrementSafe = () => {
this.setState(prevState => ({
count: prevState.count + 1
}))
}
// The difference with multiple calls
incrementMultiple = () => {
// ❌ Calling it 3 times only increments by 1 (the updates are batched)
this.setState({ count: this.state.count + 1 })
this.setState({ count: this.state.count + 1 })
this.setState({ count: this.state.count + 1 })
// ✅ Increments by 3
this.setState(prev => ({ count: prev.count + 1 }))
this.setState(prev => ({ count: prev.count + 1 }))
this.setState(prev => ({ count: prev.count + 1 }))
}
render() {
return (
<button onClick={this.incrementSafe}>
{this.state.count}
</button>
)
}
}
this.setState is processed asynchronously. If you want to use the updated value immediately, use the callback in the second argument.
this.setState({ count: 10 }, () => {
console.log('Updated count:', this.state.count) // 10
})
Lifecycle methods
Class components have methods that are called at each stage from a component's creation to its destruction.
The lifecycle flow
Mount Update Unmount
-------- ---- ----------
constructor() getDerivedStateFromProps
↓ ↓
getDerivedStateFromProps shouldComponentUpdate()
↓ ↓
render() render()
↓ ↓
componentDidMount() componentDidUpdate() componentWillUnmount()
componentDidMount
Called right after the component is mounted to the DOM. This corresponds to useEffect with an empty dependency array.
class UserProfile extends Component<{ userId: string }, { user: User | null }> {
state: { user: User | null } = { user: null }
componentDidMount() {
// Data fetching, subscriptions, DOM manipulation, and so on
this.fetchUser()
}
fetchUser = async () => {
const user = await fetchUser(this.props.userId)
this.setState({ user })
}
render() {
const { user } = this.state
if (!user) return <p>Loading...</p>
return <p>{user.name}</p>
}
}
When you use null as the initial value with a class field, add an explicit type annotation to the state field as shown above. Without the annotation, the state type is inferred from the initializer as { user: null }, and referencing user.name becomes a type error.
componentDidUpdate
Called right after an update occurs. This corresponds to useEffect with a dependency array.
class UserProfile extends Component<{ userId: string }, { user: User | null }> {
state: { user: User | null } = { user: null }
componentDidMount() {
this.fetchUser()
}
componentDidUpdate(prevProps: { userId: string }) {
// Refetch only when the props change
if (prevProps.userId !== this.props.userId) {
this.fetchUser()
}
}
fetchUser = async () => {
const user = await fetchUser(this.props.userId)
this.setState({ user })
}
render() {
// ...
}
}
When you call setState inside componentDidUpdate, always include a conditional branch. Otherwise you create an infinite loop.
componentWillUnmount
Called right before the component is unmounted. This corresponds to a useEffect cleanup function.
class Timer extends Component<{}, { seconds: number }> {
state = { seconds: 0 }
timerId: number | null = null
componentDidMount() {
this.timerId = window.setInterval(() => {
this.setState(prev => ({ seconds: prev.seconds + 1 }))
}, 1000)
}
componentWillUnmount() {
// Clean up resources
if (this.timerId) {
clearInterval(this.timerId)
}
}
render() {
return <p>{this.state.seconds}s</p>
}
}
The correspondence between the lifecycle and Hooks
Why you should understand this correspondence table
When migrating an existing class component to a function component, understanding this correspondence makes the work smoother. It also makes it easier to convert to a function component when a library's sample code is written with class components.
| Class component | Function component (Hooks) |
|---|---|
componentDidMount | useEffect(() => {}, []) |
componentDidUpdate | useEffect(() => {}, [deps]) |
componentWillUnmount | useEffect(() => { return () => {} }, []) |
this.state | useState |
this.setState | the setState function |
// Class component
class Example extends Component {
state = { data: null }
componentDidMount() {
fetchData().then(data => this.setState({ data }))
}
componentWillUnmount() {
cleanup()
}
render() {
return <div>{this.state.data}</div>
}
}
// Function component (equivalent)
function Example() {
const [data, setData] = useState(null)
useEffect(() => {
fetchData().then(setData)
return () => cleanup()
}, [])
return <div>{data}</div>
}
Error Boundary
An Error Boundary is a feature that catches JavaScript errors that occur in the child component tree and displays a fallback UI.
A basic Error Boundary
import { Component, type ReactNode, type ErrorInfo } from 'react'
type ErrorBoundaryProps = {
children: ReactNode
fallback?: ReactNode
}
type ErrorBoundaryState = {
hasError: boolean
error: Error | null
}
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props)
this.state = { hasError: false, error: null }
}
// Update the state when an error occurs
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error }
}
// Log the error information
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Error caught:', error)
console.error('Component stack:', errorInfo.componentStack)
// Send to an error tracking service
// logErrorToService(error, errorInfo)
}
render() {
if (this.state.hasError) {
return this.props.fallback ?? (
<div className="p-4 bg-red-50 border border-red-200 rounded">
<h2 className="text-red-700 font-bold">An error occurred</h2>
<p className="text-red-600">{this.state.error?.message}</p>
</div>
)
}
return this.props.children
}
}
export default ErrorBoundary
Using the Error Boundary
function App() {
return (
<div>
<ErrorBoundary fallback={<p>Failed to load the header</p>}>
<Header />
</ErrorBoundary>
<ErrorBoundary fallback={<p>Failed to load the main content</p>}>
<Main />
</ErrorBoundary>
<Footer />
</div>
)
}
An Error Boundary with a reset feature
type ErrorBoundaryProps = {
children: ReactNode
onReset?: () => void
}
type ErrorBoundaryState = {
hasError: boolean
error: Error | null
}
class ResettableErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props)
this.state = { hasError: false, error: null }
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error }
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Error:', error, errorInfo)
}
handleReset = () => {
this.setState({ hasError: false, error: null })
this.props.onReset?.()
}
render() {
if (this.state.hasError) {
return (
<div className="p-4 bg-red-50 border border-red-200 rounded">
<h2 className="text-red-700 font-bold">An error occurred</h2>
<p className="text-red-600 mb-4">{this.state.error?.message}</p>
<button
onClick={this.handleReset}
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
>
Retry
</button>
</div>
)
}
return this.props.children
}
}
Errors that an Error Boundary cannot catch
The following errors cannot be caught by an Error Boundary.
// ❌ Errors inside event handlers
<button onClick={() => { throw new Error('Click error') }}>
Click
</button>
// ❌ Asynchronous code (setTimeout, Promise)
useEffect(() => {
setTimeout(() => {
throw new Error('Async error')
}, 1000)
}, [])
// ❌ Server-side rendering
// ❌ Errors thrown by the Error Boundary itself
Handle errors in event handlers and asynchronous code individually with try-catch.
function SaveButton() {
const handleSave = async () => {
try {
await saveData()
} catch (error) {
// Error handling
alert('Failed to save')
}
}
return <button onClick={handleSave}>Save</button>
}
Class component patterns
Binding methods
class Example extends Component {
// Approach 1: bind in the constructor
constructor(props: {}) {
super(props)
this.handleClick = this.handleClick.bind(this)
}
handleClick() {
console.log(this) // the component instance
}
// Approach 2: an arrow function (recommended)
handleClickArrow = () => {
console.log(this) // the component instance
}
render() {
return (
<div>
<button onClick={this.handleClick}>Click</button>
<button onClick={this.handleClickArrow}>Click</button>
</div>
)
}
}
defaultProps and propTypes
import PropTypes from 'prop-types'
class Button extends Component<{ label?: string; onClick?: () => void }> {
static defaultProps = {
label: 'Click me'
}
// propTypes is a legacy notation from before React 18 (in React 19 it is silently ignored even if specified)
static propTypes = {
label: PropTypes.string,
onClick: PropTypes.func
}
render() {
return <button onClick={this.props.onClick}>{this.props.label}</button>
}
}
In React 19, the propTypes checking mechanism itself was removed from React core, and it is silently ignored even if specified. Do type checking with TypeScript. Note that static defaultProps still works in class components (it was removed from function components, so use ES6 default arguments there).
Migrating from a class to a function
An example of migrating an existing class component to a function component.
Before: a class component
class UserList extends Component<{ filter: string }, { users: User[]; loading: boolean }> {
state: { users: User[]; loading: boolean } = {
users: [],
loading: true
}
componentDidMount() {
this.fetchUsers()
}
componentDidUpdate(prevProps: { filter: string }) {
if (prevProps.filter !== this.props.filter) {
this.fetchUsers()
}
}
fetchUsers = async () => {
this.setState({ loading: true })
const users = await fetchUsers(this.props.filter)
this.setState({ users, loading: false })
}
render() {
const { users, loading } = this.state
if (loading) return <p>Loading...</p>
return (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)
}
}
After: a function component
function UserList({ filter }: { filter: string }) {
const [users, setUsers] = useState<User[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
const fetchData = async () => {
setLoading(true)
const data = await fetchUsers(filter)
setUsers(data)
setLoading(false)
}
fetchData()
}, [filter])
if (loading) return <p>Loading...</p>
return (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)
}
Try it
Take on the following challenges to deepen your understanding of class components.
-
Build a counter with a class component: without using
useState, create a counter with "+1", "-1", and "reset" buttons using a class component. -
Experience the lifecycle: add a
console.logto each ofcomponentDidMount,componentDidUpdate, andcomponentWillUnmount, and confirm that the logs are printed when the component mounts, updates, and unmounts. -
Implement an Error Boundary: implement an Error Boundary with a reset feature yourself, and wrap a component that intentionally throws an error. Confirm that the "retry" button resets the error state.
Hint
- Initialize with
state = { count: 0 }and update withthis.setState({ count: this.state.count + 1 }). For reset, set{ count: 0 }. - Conditionally render the child in the parent component, and toggling between show/hide calls
componentWillUnmount. - Set the error state in
getDerivedStateFromErrorand revert tohasError: falsein thehandleResetmethod.
Answer and explanation
Challenge 1: Build a counter with a class component
import { Component } from 'react'
import './App.css'
class Counter extends Component<{}, { count: number }> {
state = { count: 0 }
increment = () => {
this.setState({ count: this.state.count + 1 })
}
decrement = () => {
this.setState({ count: this.state.count - 1 })
}
reset = () => {
this.setState({ count: 0 })
}
render() {
return (
<div className="p-4">
<p className="text-2xl mb-4">{this.state.count}</p>
<div className="space-x-2">
<button onClick={this.decrement} className="px-4 py-2 bg-red-500 text-white rounded">
-1
</button>
<button onClick={this.reset} className="px-4 py-2 bg-gray-500 text-white rounded">
Reset
</button>
<button onClick={this.increment} className="px-4 py-2 bg-blue-500 text-white rounded">
+1
</button>
</div>
</div>
)
}
}
export default function App() {
return <Counter />
}
In a class component, you define state with the state property and update it with this.setState(). Defining methods as arrow functions automatically handles the this binding. Compared with the function component's useState, you can see that the way state is defined and updated differs.
Challenge 2: Experience the lifecycle
import { Component, useState } from 'react'
import './App.css'
// Child component (has a lifecycle)
class LifecycleDemo extends Component<{ name: string }, { count: number }> {
state = { count: 0 }
componentDidMount() {
console.log('componentDidMount: the component was mounted')
}
componentDidUpdate(prevProps: { name: string }, prevState: { count: number }) {
console.log('componentDidUpdate: the component was updated')
console.log(' previous props:', prevProps, '→ current:', this.props)
console.log(' previous state:', prevState, '→ current:', this.state)
}
componentWillUnmount() {
console.log('componentWillUnmount: the component will unmount')
}
render() {
return (
<div className="p-4 border rounded">
<p>Name: {this.props.name}</p>
<p>Count: {this.state.count}</p>
<button
onClick={() => this.setState({ count: this.state.count + 1 })}
className="mt-2 px-4 py-2 bg-blue-500 text-white rounded"
>
Count up
</button>
</div>
)
}
}
// Parent component (controls show/hide)
function App() {
const [isVisible, setIsVisible] = useState(true)
const handleToggle = () => {
setIsVisible(!isVisible)
}
return (
<div className="p-4">
<button
onClick={handleToggle}
className="mb-4 px-4 py-2 bg-gray-500 text-white rounded"
>
{isVisible ? 'Hide' : 'Show'}
</button>
{isVisible && <LifecycleDemo name="React" />}
</div>
)
}
export default App
Open the console and confirm the following:
componentDidMountis printed when the page loadscomponentDidUpdateis printed when you press the count-up buttoncomponentWillUnmountis printed when you press "Hide"
Challenge 3: Implement an Error Boundary
import { Component, type ReactNode, type ErrorInfo, useState } from 'react'
import './App.css'
// Error Boundary
type ErrorBoundaryProps = {
children: ReactNode
onReset?: () => void
}
type ErrorBoundaryState = {
hasError: boolean
error: Error | null
}
class ResettableErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props)
this.state = { hasError: false, error: null }
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error }
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Error caught by boundary:', error)
console.error('Component stack:', errorInfo.componentStack)
}
handleReset = () => {
this.setState({ hasError: false, error: null })
this.props.onReset?.()
}
render() {
if (this.state.hasError) {
return (
<div className="p-4 bg-red-50 border border-red-200 rounded">
<h2 className="text-red-700 font-bold">An error occurred</h2>
<p className="text-red-600 mb-4">{this.state.error?.message}</p>
<button
onClick={this.handleReset}
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
>
Retry
</button>
</div>
)
}
return this.props.children
}
}
// A component that intentionally throws an error
function BuggyComponent() {
const [shouldError, setShouldError] = useState(false)
const handleTriggerError = () => {
setShouldError(true)
}
if (shouldError) {
throw new Error('An intentionally triggered error')
}
return (
<div className="p-4 border rounded">
<p>Working normally</p>
<button
onClick={handleTriggerError}
className="mt-2 px-4 py-2 bg-red-500 text-white rounded"
>
Trigger an error
</button>
</div>
)
}
// Usage
function App() {
const [key, setKey] = useState(0)
return (
<div className="p-4">
<ResettableErrorBoundary onReset={() => setKey(k => k + 1)}>
<BuggyComponent key={key} />
</ResettableErrorBoundary>
</div>
)
}
export default App
When you click the "Trigger an error" button, the error is caught and the fallback UI is displayed. When you click "Retry", handleReset is called and the state reverts to hasError: false. Changing key completely resets the component.
Summary
- Class components are needed to understand existing code and for Error Boundaries
- Manage state with
this.stateandthis.setState - Lifecycle methods (componentDidMount, componentDidUpdate, componentWillUnmount)
- Catch rendering errors with an Error Boundary
- Use function components + Hooks for new development
In the next chapter, you will learn about server-side rendering with Next.js and the latest React features.