Skip to main content

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
info

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

  1. Understanding legacy code: existing projects may contain class components
  2. Error Boundary: can only be implemented with class components
  3. Understanding the lifecycle: you can deeply understand how React works
info

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>
)
}
}
warning

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() {
// ...
}
}
warning

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 componentFunction component (Hooks)
componentDidMountuseEffect(() => {}, [])
componentDidUpdateuseEffect(() => {}, [deps])
componentWillUnmountuseEffect(() => { return () => {} }, [])
this.stateuseState
this.setStatethe 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>
}
}
info

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.

  1. Build a counter with a class component: without using useState, create a counter with "+1", "-1", and "reset" buttons using a class component.

  2. Experience the lifecycle: add a console.log to each of componentDidMount, componentDidUpdate, and componentWillUnmount, and confirm that the logs are printed when the component mounts, updates, and unmounts.

  3. 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
  1. Initialize with state = { count: 0 } and update with this.setState({ count: this.state.count + 1 }). For reset, set { count: 0 }.
  2. Conditionally render the child in the parent component, and toggling between show/hide calls componentWillUnmount.
  3. Set the error state in getDerivedStateFromError and revert to hasError: false in the handleReset method.
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:

  • componentDidMount is printed when the page loads
  • componentDidUpdate is printed when you press the count-up button
  • componentWillUnmount is 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.state and this.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.