Skip to main content

Debugging

In this chapter, you learn debugging techniques for React applications and how to use the React Developer Tools.

What you learn in this chapter

  • Leveraging console.log and the variations of console
  • React Developer Tools (Components, Profiler)
  • Error handling with Error Boundaries
  • Causes of common errors and how to deal with them
info

This chapter uses the project created in Chapter 3. Start the dev server with npm run dev and learn while writing code.

info

Debugging skills are as important as the ability to write code. Being able to quickly identify and resolve problems greatly affects your development efficiency. In this chapter, we cover tools and techniques you can use for efficient debugging.

Debugging basics

Leveraging console.log

Is console.log looked down upon?

console.log is often thought of as "for beginners," but experienced engineers use it daily. In many cases, adding logs at the right places identifies a problem faster than setting up an advanced debugger. However, be careful not to leave logs in production code.

The simplest debugging method.

function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null)

console.log('UserProfile rendered, userId:', userId)

useEffect(() => {
console.log('Effect running, fetching user:', userId)

fetchUser(userId).then(data => {
console.log('User fetched:', data)
setUser(data)
})

return () => {
console.log('Cleanup, userId was:', userId)
}
}, [userId])

console.log('Current user state:', user)

return user ? <div>{user.name}</div> : <div>Loading...</div>
}

The variations of console

// Grouping
console.group('User Data')
console.log('Name:', user.name)
console.log('Email:', user.email)
console.groupEnd()

// Table display
console.table(users)

// Conditional log
console.assert(user !== null, 'User should not be null')

// Warning / error
console.warn('This is deprecated')
console.error('Something went wrong')

// Time measurement
console.time('fetchData')
await fetchData()
console.timeEnd('fetchData') // fetchData: 234.56ms

React Developer Tools

The React Developer Tools are an essential tool for debugging React applications.

Installation

The Components tab

You can visually inspect the component tree.

Main features:

  1. Inspecting the component hierarchy: displayed as a tree structure
  2. Inspecting Props/State: displays the values of the selected component
  3. Editing values: change Props/State in real time
  4. Searching components: filter by name
  5. Jumping to source code: click the <> icon
How to read the Components tab:

App
├── Header
│ └── UserMenu
│ props: { user: {...} }
├── Main
│ ├── ProductList
│ │ state: { products: [...], loading: false }
│ │ └── ProductItem (×10)
│ └── Sidebar
└── Footer

The Profiler tab

Measures rendering performance.

How to use it:

  1. Click the "Record" button
  2. Interact with the app
  3. Click "Stop"
  4. Analyze the results

The information displayed:

  • The rendering time of each component
  • Why it re-rendered
  • Information per commit
How to read the Profiler:

Commit 1 (32ms)
├── App (2ms)
│ rendered because: Hook 1 changed
├── ProductList (25ms)
│ rendered because: Props changed
└── Footer (0.5ms)
rendered because: Parent rendered

Settings options

You can configure them from the DevTools gear icon.

  • Highlight updates: highlight re-rendered components
  • Hide components where...: hide specific components
  • Record why each component rendered: record the reason for re-rendering

Breakpoint debugging

Debugging using the browser's DevTools.

VSCode debugging configuration

// .vscode/launch.json
{
"version": "0.2.0",
"configurations": [
{
"type": "chrome",
"request": "launch",
"name": "Debug React App",
"url": "http://localhost:5173",
"webRoot": "${workspaceFolder}/src"
}
]
}

The debugger statement

Set a breakpoint in your code.

function handleSubmit(e: React.FormEvent) {
e.preventDefault()

debugger // execution stops here

const formData = new FormData(e.target as HTMLFormElement)
// ...
}

Error handling

Error Boundary

When an error occurs in part of the component tree, it displays a fallback without crashing the UI.

import { Component, ReactNode } 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 }
}

static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error }
}

componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
// Send to an error logging service
console.error('Error caught by boundary:', error)
console.error('Component stack:', errorInfo.componentStack)
}

handleRetry = () => {
this.setState({ hasError: false, error: null })
}

render() {
if (this.state.hasError) {
return this.props.fallback ?? (
<div className="p-4 bg-red-100 text-red-700 rounded">
<h2>An error occurred</h2>
<p>{this.state.error?.message}</p>
<button
onClick={this.handleRetry}
className="mt-2 px-4 py-2 bg-red-500 text-white rounded"
>
Retry
</button>
</div>
)
}

return this.props.children
}
}

// Usage
function App() {
return (
<ErrorBoundary fallback={<div>A problem occurred</div>}>
<UserProfile />
</ErrorBoundary>
)
}
info

Error Boundaries can only be implemented with class components. In function components, use the react-error-boundary library.

react-error-boundary

npm install react-error-boundary
import { ErrorBoundary } from 'react-error-boundary'

function ErrorFallback({ error, resetErrorBoundary }: {
error: Error
resetErrorBoundary: () => void
}) {
return (
<div className="p-4 bg-red-100 text-red-700 rounded">
<h2>An error occurred</h2>
<pre className="text-sm">{error.message}</pre>
<button
onClick={resetErrorBoundary}
className="mt-2 px-4 py-2 bg-red-500 text-white rounded"
>
Retry
</button>
</div>
)
}

function App() {
return (
<ErrorBoundary
FallbackComponent={ErrorFallback}
onError={(error, info) => {
// Send the error log
logErrorToService(error, info)
}}
onReset={() => {
// Reset the state
}}
>
<MainContent />
</ErrorBoundary>
)
}

Common errors and how to deal with them

1. "Cannot read properties of undefined"

// ❌ May cause an error
function UserProfile({ user }: { user: User | undefined }) {
return <p>{user.name}</p> // error when user is undefined
}

// ✅ Handle with optional chaining
function UserProfile({ user }: { user: User | undefined }) {
return <p>{user?.name ?? 'Guest'}</p>
}

// ✅ Handle with an early return
function UserProfile({ user }: { user: User | undefined }) {
if (!user) {
return <p>No user information</p>
}
return <p>{user.name}</p>
}

2. "Too many re-renders"

An infinite loop is occurring.

// ❌ Calling setState during render
function BadComponent() {
const [count, setCount] = useState(0)
setCount(count + 1) // infinite loop!
return <p>{count}</p>
}

// ❌ An inappropriate dependency array in useEffect
function BadComponent() {
const [data, setData] = useState({})

useEffect(() => {
setData({ updated: true }) // data changes → effect re-runs → infinite loop
}, [data])
}

// ✅ A correct dependency array
function GoodComponent() {
const [data, setData] = useState({})
const [shouldUpdate, setShouldUpdate] = useState(false)

useEffect(() => {
if (shouldUpdate) {
setData({ updated: true })
setShouldUpdate(false)
}
}, [shouldUpdate])
}

3. "Each child in a list should have a unique key prop"

warning

This error is often overlooked, but it becomes a source of performance issues and bugs. Using the index as the key can cause unintended behavior when reordering or deleting. Use a unique ID whenever possible.

List items need a key.

// ❌ No key
{items.map(item => (
<li>{item.name}</li>
))}

// ❌ The index as the key (problematic when reordering)
{items.map((item, index) => (
<li key={index}>{item.name}</li>
))}

// ✅ A unique ID as the key
{items.map(item => (
<li key={item.id}>{item.name}</li>
))}

4. "Cannot update a component while rendering"

// ❌ Updating the parent's state during render
function Child({ onMount }: { onMount: () => void }) {
onMount() // calling the parent's setState triggers a warning
return <div>Child</div>
}

// ✅ Update with useEffect
function Child({ onMount }: { onMount: () => void }) {
useEffect(() => {
onMount()
}, [onMount])

return <div>Child</div>
}

5. "Objects are not valid as a React child"

// ❌ Displaying an object as-is
function BadComponent({ user }: { user: User }) {
return <p>{user}</p> // [object Object] or an error
}

// ✅ Specify a property
function GoodComponent({ user }: { user: User }) {
return <p>{user.name}</p>
}

// ✅ Debug display with JSON.stringify
function DebugComponent({ data }: { data: unknown }) {
return <pre>{JSON.stringify(data, null, 2)}</pre>
}

Leveraging StrictMode

Detects potential problems during development.

// main.tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'

createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
)

The effects of StrictMode:

  1. Detecting side effects: runs useEffect twice to detect problems
  2. Warnings for deprecated APIs: warns when you use old APIs
  3. Detecting unexpected side effects: runs the render function twice
info

StrictMode's double execution is development-mode only. In a production build, it runs only once.

Network debugging

The browser's Network tab

Used to inspect API requests.

How to read the Network tab:

Name Status Type Size Time
/api/users 200 fetch 1.2 KB 234ms
/api/posts 404 fetch 128 B 156ms

Mocking requests

Mock API responses during development.

// Using msw (Mock Service Worker)
import { http, HttpResponse } from 'msw'
import { setupWorker } from 'msw/browser'

const handlers = [
http.get('/api/users', () => {
return HttpResponse.json([
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' }
])
}),

http.post('/api/users', async ({ request }) => {
const body = await request.json()
return HttpResponse.json({ id: 3, ...body }, { status: 201 })
})
]

const worker = setupWorker(...handlers)
worker.start()

Debugging best practices

1. Make the problem reproducible

// Clarify the reproduction steps
// 1. Log in
// 2. Add a product to the cart
// 3. Click the checkout button
// → an error occurs

2. Isolate the problem

// Minimize the component to identify the problem
function DebugComponent() {
const [data, setData] = useState(null)

// Reproduce the problem with minimal code
useEffect(() => {
fetchData().then(setData)
}, [])

return <pre>{JSON.stringify(data, null, 2)}</pre>
}
// Comment out half of the code to narrow down the problem
function ComplexComponent() {
// Code that may have the problem
const partA = useFeatureA()

// Comment out the following to isolate
// const partB = useFeatureB()
// const partC = useFeatureC()

return <div>{/* ... */}</div>
}

4. Structure your logs

const DEBUG = true

function debugLog(context: string, data: unknown) {
if (DEBUG) {
console.log(`[${context}]`, data)
}
}

function UserProfile({ userId }: { userId: string }) {
debugLog('UserProfile:render', { userId })

useEffect(() => {
debugLog('UserProfile:effect', { userId })
}, [userId])

return <div>...</div>
}

Try it

Take on the following challenges to sharpen your debugging skills.

  1. Leverage the React Developer Tools: in the Components tab, inspect the component tree of the Chapter 3 project. Select any component and edit its Props or State values directly from DevTools.

  2. Measure with console.time: measure the execution time of a data fetch with console.time/console.timeEnd. If there are multiple API calls, identify which one takes the longest.

  3. Implement an Error Boundary: create a component that intentionally throws an error, catch it with an Error Boundary, and display a fallback UI. Also add a feature to reset the state with a "retry" button.

Hint
  1. After selecting a component in the Components tab, double-click the Props or State values in the right panel to edit them.
  2. Use the same label for console.time('fetchUsers') and console.timeEnd('fetchUsers'). Place them before and after the asynchronous processing.
  3. Implement the Error Boundary with a class component, or use the react-error-boundary library. The resetErrorBoundary function can reset the error state.
Answer and explanation

Challenge 1: Leverage the React Developer Tools

  1. Open the app in the browser and launch DevTools (F12)
  2. Select the "Components" tab
  3. Click any component in the component tree
  4. Double-click the Props or State values displayed in the right panel
  5. Enter a new value and confirm with Enter
// Example: you can change this component's count directly from DevTools
function Counter() {
const [count, setCount] = useState(0)
return <p>Count: {count}</p>
}

When you edit a State value directly from DevTools, it is immediately reflected in the UI. This is useful when you want to reproduce a specific state during debugging. Also, with a component selected, clicking the bug icon in the right panel (Log this component data to the console) prints the component's detailed information to the console.


Challenge 2: Measure with console.time

import { useState, useEffect } from 'react'

type User = { id: number; name: string }
type Post = { id: number; title: string }

function DataFetcher() {
const [users, setUsers] = useState<User[]>([])
const [posts, setPosts] = useState<Post[]>([])

useEffect(() => {
const fetchData = async () => {
// Measure the user fetch
console.time('fetchUsers')
const usersRes = await fetch('https://jsonplaceholder.typicode.com/users')
const usersData = await usersRes.json()
console.timeEnd('fetchUsers') // e.g., fetchUsers: 234.56ms

// Measure the post fetch
console.time('fetchPosts')
const postsRes = await fetch('https://jsonplaceholder.typicode.com/posts')
const postsData = await postsRes.json()
console.timeEnd('fetchPosts') // e.g., fetchPosts: 189.23ms

setUsers(usersData)
setPosts(postsData)
}

// Measure the total
console.time('totalFetch')
fetchData().then(() => {
console.timeEnd('totalFetch') // e.g., totalFetch: 425.12ms
})
}, [])

return (
<div>
<h2>Users: {users.length}</h2>
<h2>Posts: {posts.length}</h2>
</div>
)
}

The execution time of each API call is displayed in the console. You can identify which API takes the longest, which is useful when deciding the priority of performance improvements. When you want to fetch in parallel, use Promise.all.


Challenge 3: Implement an Error Boundary

import { Component, ReactNode, ErrorInfo, useState } from 'react'

// Error Boundary component
type ErrorBoundaryProps = {
children: 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 }
}

static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error }
}

componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Error caught:', error)
console.error('Component stack:', errorInfo.componentStack)
}

handleReset = () => {
this.setState({ hasError: false, error: null })
}

render() {
if (this.state.hasError) {
return (
<div className="p-4 bg-red-100 text-red-700 rounded">
<h2 className="font-bold">An error occurred</h2>
<p className="text-sm mt-1">{this.state.error?.message}</p>
<button
onClick={this.handleReset}
className="mt-3 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 intentional error!')
}

return (
<button
onClick={handleTriggerError}
className="px-4 py-2 bg-blue-500 text-white rounded"
>
Trigger an error
</button>
)
}

// Usage
function App() {
return (
<ErrorBoundary>
<BuggyComponent />
</ErrorBoundary>
)
}

When you click the "Trigger an error" button, an error occurs, and the Error Boundary catches it and displays the fallback UI. When you click the "Retry" button, hasError is reset to false, and BuggyComponent is re-rendered (shouldError is also reset, so it displays normally).

Summary

  • console.log is the simplest and most effective debugging tool
  • Inspect the component tree and performance with the React Developer Tools
  • Handle errors appropriately with Error Boundaries
  • Discover potential problems early with StrictMode
  • Make problems reproducible and isolate them to identify the cause

In the next chapter, you will learn in more detail about class components and Error Boundaries.