Skip to main content

Performance optimization

In this chapter, you learn techniques for optimizing the performance of React applications.

What you learn in this chapter

  • How automatic memoization with the React Compiler works, and how to choose between it and manual memoization
  • How re-rendering works and what triggers it
  • How to choose between React.memo, useMemo, and useCallback (for when you cannot use the Compiler)
  • Improving UI responsiveness with useTransition and useDeferredValue
  • Code splitting and list virtualization
info

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

info

Performance optimization is fundamentally something you address "after a problem occurs." Optimize based on "measurement, not guesswork." React is fast enough by default, and in most cases optimization is unnecessary. Optimization code adds complexity, so apply it only when it is truly needed.

Automatic memoization with the React Compiler

The React Compiler is a compiler that analyzes your code at build time and automatically memoizes only the necessary parts. v1.0 was released in October 2025, and in Next.js 16 it is treated as stable (though it is disabled by default and you enable it explicitly in the config).

What the React Compiler does automatically
├─ Memoize values that do not change (equivalent to useMemo)
├─ Memoize functions that do not change (equivalent to useCallback)
└─ Skip unnecessary re-renders (equivalent to React.memo)

In other words, once you enable the React Compiler, in a new project you almost never need to write useMemo / useCallback / React.memo by hand.

info

When the React Compiler is enabled, the React.memo / useMemo / useCallback explained in the second half of this chapter are generally unnecessary. Consider manual memoization only in the following exceptional cases.

  • A third-party library depends on referential equality (===)
  • An environment where you cannot use the Compiler (old build tools, React 18 or earlier)
  • You measured with a profiler and confirmed that the Compiler's automatic memoization alone is insufficient

Enabling the React Compiler

In a Vite + React setup, you incorporate the Babel plugin babel-plugin-react-compiler via @rolldown/plugin-babel. From Next.js 16 onward, enable it with reactCompiler: true in next.config.js.

npm install -D babel-plugin-react-compiler @rolldown/plugin-babel @babel/core @types/babel__core
// vite.config.ts
import { defineConfig } from 'vite'
import react, { reactCompilerPreset } from '@vitejs/plugin-react'
import babel from '@rolldown/plugin-babel'

export default defineConfig({
plugins: [
react(),
babel({ presets: [reactCompilerPreset()] }),
],
})

Note that passing it to the babel option, as in react({ babel: { plugins: [...] } }), is the old configuration for @vitejs/plugin-react 5 and earlier (up to Vite 7). The babel option was removed in v6.0.0, so now you use the configuration above.

info

create-vite 9 provides a "TypeScript + React Compiler" template. For a new project, just selecting this template at scaffold time enables the React Compiler.

info

The React Compiler only optimizes code that follows the "Rules of React." Functions with side effects, or code that violates the Rules of Hooks, are skipped, so you can adopt it safely. You can detect violations with the recommended-latest configuration of the ESLint plugin eslint-plugin-react-hooks (the Compiler rules are integrated from v6 onward).

How to read this chapter

The React.memo / useMemo / useCallback explained below should be positioned as optimization techniques on the premise that you do not use the Compiler. In an environment where the Compiler is enabled, the same effects are obtained automatically. Reading them as material to understand the internal mechanism also helps you understand what the Compiler is doing.

How re-rendering works

First, let's understand when React re-renders.

Re-render triggers

  1. A change in State
  2. A change in Props
  3. A re-render of the parent component
  4. A change in a Context value
function Parent() {
const [count, setCount] = useState(0)

console.log('Parent rendered')

return (
<div>
<button onClick={() => setCount(c => c + 1)}>
{count}
</button>
<Child /> {/* when Parent re-renders, Child also re-renders */}
</div>
)
}

function Child() {
console.log('Child rendered')
return <p>Child component</p>
}

React.memo

React.memo is a higher-order component that skips re-rendering as long as the Props do not change.

Basic usage

import { memo, useState } from 'react'

type ExpensiveComponentProps = {
data: string
}

const ExpensiveComponent = memo(function ExpensiveComponent({ data }: ExpensiveComponentProps) {
console.log('ExpensiveComponent rendered')
// Heavy processing...
return <div>{data}</div>
})

// Usage
function Parent() {
const [count, setCount] = useState(0)
const [text, setText] = useState('Hello')

return (
<div>
<button onClick={() => setCount(c => c + 1)}>
Count: {count}
</button>
<ExpensiveComponent data={text} />
{/* as long as text does not change, ExpensiveComponent does not re-render */}
</div>
)
}

A custom comparison function

By default, a shallow comparison is performed. When you need a custom comparison, specify it as the second argument.

type UserProps = {
user: {
id: number
name: string
email: string
}
}

const UserCard = memo(
function UserCard({ user }: UserProps) {
return (
<div>
<h3>{user.name}</h3>
<p>{user.email}</p>
</div>
)
},
(prevProps, nextProps) => {
// Returning true skips re-rendering
return prevProps.user.id === nextProps.user.id
}
)
warning

Avoid overusing custom comparison functions. In most cases, it is more appropriate to reconsider your data structure or use the useMemo described below.

useMemo

useMemo memoizes a computation result and prevents recomputation until the dependency array changes.

Memoizing an expensive computation

import { useMemo, useState } from 'react'

function ProductList({ products }: { products: Product[] }) {
const [filter, setFilter] = useState('')
const [sortBy, setSortBy] = useState<'name' | 'price'>('name')

// As long as filter and sortBy do not change, it is not recomputed
const filteredAndSortedProducts = useMemo(() => {
console.log('Filtering and sorting...')

return products
.filter(p => p.name.toLowerCase().includes(filter.toLowerCase()))
.sort((a, b) => {
if (sortBy === 'name') {
return a.name.localeCompare(b.name)
}
return a.price - b.price
})
}, [products, filter, sortBy])

return (
<div>
<input
value={filter}
onChange={e => setFilter(e.target.value)}
placeholder="Search..."
/>
<select value={sortBy} onChange={e => setSortBy(e.target.value as 'name' | 'price')}>
<option value="name">By name</option>
<option value="price">By price</option>
</select>

<ul>
{filteredAndSortedProducts.map(product => (
<li key={product.id}>{product.name}: ¥{product.price}</li>
))}
</ul>
</div>
)
}

Stabilizing object references

When combining with React.memo, use it to stabilize an object's reference.

function Parent() {
const [count, setCount] = useState(0)

// A new object is created every time → Child re-renders
// const config = { theme: 'dark', size: 'large' }

// Stabilize the reference with useMemo
const config = useMemo(() => ({
theme: 'dark',
size: 'large'
}), []) // empty dependency array = created only on the first render

return (
<div>
<button onClick={() => setCount(c => c + 1)}>{count}</button>
<MemoizedChild config={config} />
</div>
)
}

const MemoizedChild = memo(function Child({ config }: { config: Config }) {
console.log('Child rendered')
return <div>{config.theme}</div>
})

useCallback

useCallback memoizes a function and keeps the same function reference until the dependency array changes.

Basic usage

import { useCallback, useState, memo } from 'react'

function Parent() {
const [count, setCount] = useState(0)
const [text, setText] = useState('')

// A new function is created every time
// const handleClick = () => console.log('clicked')

// Memoize the function with useCallback
const handleClick = useCallback(() => {
console.log('clicked')
}, []) // no dependencies

const handleTextChange = useCallback((newText: string) => {
setText(newText)
}, []) // setText is a stable reference, so no dependency is needed

return (
<div>
<button onClick={() => setCount(c => c + 1)}>{count}</button>
<MemoizedButton onClick={handleClick} />
<MemoizedInput onChange={handleTextChange} value={text} />
</div>
)
}

const MemoizedButton = memo(function Button({ onClick }: { onClick: () => void }) {
console.log('Button rendered')
return <button onClick={onClick}>Click</button>
})

const MemoizedInput = memo(function Input({
value,
onChange
}: {
value: string
onChange: (value: string) => void
}) {
console.log('Input rendered')
return (
<input
value={value}
onChange={e => onChange(e.target.value)}
/>
)
})

Caveats about the dependency array

function SearchForm({ onSearch }: { onSearch: (query: string) => void }) {
const [query, setQuery] = useState('')

// If onSearch is not in the dependency array, you may keep using a stale onSearch
const handleSubmit = useCallback((e: React.FormEvent) => {
e.preventDefault()
onSearch(query)
}, [query, onSearch])

return (
<form onSubmit={handleSubmit}>
<input value={query} onChange={e => setQuery(e.target.value)} />
<button type="submit">Search</button>
</form>
)
}

Cases where optimization is unnecessary

warning

Over-optimization makes code more complex and becomes a source of bugs. Avoid using React.memo, useMemo, and useCallback "just in case." Memoization itself has a cost, and using it inappropriately can even degrade performance.

Cases where memoization is unnecessary

// ❌ Unnecessary: a simple component
const SimpleText = memo(({ text }: { text: string }) => {
return <span>{text}</span>
})

// ❌ Unnecessary: a lightweight computation
const total = useMemo(() => items.length, [items])

// ❌ Unnecessary: a function that is only passed to a non-memoized child / DOM element
const handleClick = useCallback(() => {
setCount(c => c + 1)
}, [])
// → if the recipient is not memoized, stabilizing the reference does not reduce re-renders
// (setCount is stable so the dependency array can be empty, but that is not a reason to use useCallback)

Cases where memoization is necessary

// ✅ Necessary: an expensive computation
const sortedItems = useMemo(() => {
return [...items].sort((a, b) => complexCompare(a, b))
}, [items])

// ✅ Necessary: an object/function passed to a memoized component
const config = useMemo(() => ({ theme, size }), [theme, size])
const handleChange = useCallback((value) => onChange(value), [onChange])

// ✅ Necessary: an object/function included in useEffect's dependency array
const options = useMemo(() => ({ limit: 10 }), [])
useEffect(() => {
fetchData(options)
}, [options])

useTransition

What is Concurrent Rendering

"Concurrent Rendering," introduced in React 18, is a mechanism that lets React interrupt and resume rendering. This allows the app to respond quickly to user input even during heavy processing.

useTransition and useDeferredValue are hooks for taking advantage of this Concurrent Rendering. They mark low-priority updates as "deferrable" and prioritize high-priority updates (such as user input).

useTransition is a hook for updating state without blocking the UI. It was introduced in React 18.

Basic usage

import { useState, useTransition, useMemo } from 'react'

function SearchResults() {
const [query, setQuery] = useState('')
const [searchQuery, setSearchQuery] = useState('')
const [isPending, startTransition] = useTransition()

const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value
setQuery(value) // update immediately (high priority)

startTransition(() => {
// the re-render caused by this update becomes low priority (interruptible)
setSearchQuery(value)
})
}

// The heavy filtering runs during render (recomputed only when searchQuery changes)
const filteredItems = useMemo(() => {
return allItems.filter(item =>
item.toLowerCase().includes(searchQuery.toLowerCase())
)
}, [searchQuery])

return (
<div>
<input value={query} onChange={handleChange} />
{isPending && <span>Searching...</span>}
<ul>
{filteredItems.map(item => <li key={item}>{item}</li>)}
</ul>
</div>
)
}

A tab-switching example

function Tabs() {
const [tab, setTab] = useState('home')
const [isPending, startTransition] = useTransition()

const handleTabChange = (newTab: string) => {
startTransition(() => {
setTab(newTab)
})
}

return (
<div>
<nav>
<button onClick={() => handleTabChange('home')}>Home</button>
<button onClick={() => handleTabChange('posts')}>Posts</button>
<button onClick={() => handleTabChange('settings')}>Settings</button>
</nav>

<div style={{ opacity: isPending ? 0.7 : 1 }}>
{tab === 'home' && <HomePage />}
{tab === 'posts' && <PostsPage />} {/* a heavy component */}
{tab === 'settings' && <SettingsPage />}
</div>
</div>
)
}

useDeferredValue

useDeferredValue is a hook that defers the update of a value.

import { useDeferredValue, useState, useMemo } from 'react'

function SearchList({ items }: { items: string[] }) {
const [query, setQuery] = useState('')
const deferredQuery = useDeferredValue(query)

// By using deferredQuery, input stays smooth while the list updates with a delay
const filteredItems = useMemo(() => {
return items.filter(item =>
item.toLowerCase().includes(deferredQuery.toLowerCase())
)
}, [items, deferredQuery])

const isStale = query !== deferredQuery

return (
<div>
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search..."
/>
<ul style={{ opacity: isStale ? 0.7 : 1 }}>
{filteredItems.map(item => (
<li key={item}>{item}</li>
))}
</ul>
</div>
)
}

Optimizing lists

Optimization techniques for displaying large lists.

Using key appropriately

// ❌ Using the index as the key (problematic when reordering)
{items.map((item, index) => (
<Item key={index} data={item} />
))}

// ✅ Using a unique ID as the key
{items.map(item => (
<Item key={item.id} data={item} />
))}

Virtualization

For lists with a large number of items, use a virtualization library.

// Using @tanstack/react-virtual
import { useVirtualizer } from '@tanstack/react-virtual'
import { useRef } from 'react'

function VirtualList({ items }: { items: string[] }) {
const parentRef = useRef<HTMLDivElement>(null)

const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50, // the estimated height of each item
})

return (
<div ref={parentRef} style={{ height: '400px', overflow: 'auto' }}>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
position: 'relative'
}}
>
{virtualizer.getVirtualItems().map(virtualItem => (
<div
key={virtualItem.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`
}}
>
{items[virtualItem.index]}
</div>
))}
</div>
</div>
)
}

Code splitting

Split a large bundle and load only the parts you need.

React.lazy

import { lazy, Suspense, useState } from 'react'

// Dynamic imports
const HeavyComponent = lazy(() => import('./HeavyComponent'))
const AdminPanel = lazy(() => import('./AdminPanel'))

function App() {
const [showAdmin, setShowAdmin] = useState(false)

return (
<div>
<Suspense fallback={<div>Loading...</div>}>
<HeavyComponent />
</Suspense>

<button onClick={() => setShowAdmin(true)}>
Open the admin panel
</button>

{showAdmin && (
<Suspense fallback={<div>Loading...</div>}>
<AdminPanel />
</Suspense>
)}
</div>
)
}

Route-based splitting

import { lazy, Suspense } from 'react'
import { Routes, Route } from 'react-router'

const Home = lazy(() => import('./pages/Home'))
const About = lazy(() => import('./pages/About'))
const Dashboard = lazy(() => import('./pages/Dashboard'))

function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/dashboard" element={<Dashboard />} />
</Routes>
</Suspense>
)
}

Measuring performance

The React Developer Tools Profiler

You can measure rendering time in the "Profiler" tab of the React Developer Tools.

  1. Open DevTools and select the "Profiler" tab
  2. Open the ⚙️ (Settings) icon and check "Record why each component rendered while profiling"
  3. Click the "Record" button
  4. Interact with the app
  5. Click "Stop" and review the results

How to read "Why did this render?"

After recording, clicking each component displays the cause of the re-render in the right panel. Common causes and responses are as follows.

CauseExample displayResponse
The parent re-renderedThis component rendered because its parent re-renderedCheck whether the parent's re-render is reasonable. If unnecessary, consider React.memo or adopting the React Compiler
A change in Props (a new reference each time even though the value did not change)Props changed: (onClick)Check whether the parent recreates functions or arrays every time. Enable the Compiler or stabilize the reference with useCallback / useMemo
A change in StateHook 1 changedCheck whether the update is actually needed. Avoid unnecessary setState
A change in ContextContext changedSplit the Context (separate auth info and theme into different Contexts, etc.). For values used only conditionally, call use(Context) inside a conditional branch to avoid subscribing during unnecessary renders
info

Important: enable the React Compiler before optimizing manually. In an environment where the Compiler is enabled, many Props changed cases are resolved automatically. Use the Profiler to investigate problems for the parts that the Compiler could not improve.

Choosing between Flamegraph and Ranked

You can review the Profiler results in two kinds of views.

  • Flamegraph: displays rendering time as horizontal width while keeping the component tree structure. Good for grasping the overall picture of what is heavy
  • Ranked: orders by rendering time, longest first. Good for identifying which specific component to optimize

In many cases, the flow is to identify heavy components with Ranked and confirm their positional relationship in the tree with Flamegraph.

why-did-you-render

For detecting unnecessary re-renders, you can get equivalent information with "Record why each component rendered while profiling" in the React DevTools Profiler. The third-party why-did-you-render was a staple library in the webpack/CRA era, but in a Vite + React 19 setup it requires separate Babel wiring, so this book does not cover it (if you adopt it, refer to the official README).

Optimization checklist

  1. Is optimization actually needed? - address it after a problem occurs
  2. Measure with the React Developer Tools - based on measurement, not guesswork
  3. Identify unnecessary re-renders - confirm with the Profiler
  4. Check whether you can enable the React Compiler - if enabled, most manual memoization is unnecessary
  5. Use React.memo appropriately - only for expensive components
  6. Use useMemo/useCallback appropriately - set the dependency array correctly
  7. Virtualize large lists - consider it for 1000+ items
  8. Code splitting - keep the initial bundle small

Try it

Take on the following challenges to deepen your understanding of performance optimization.

  1. Use the React Developer Tools Profiler: open the Profiler tab in the Chapter 3 project and confirm which components re-render when you click a button. Enabling the "Highlight updates when components render" option lets you confirm it visually.

  2. Memoize a computation with useMemo: create a component that filters and sorts a list of 1000 items, and compare the number of console.log outputs with and without useMemo.

  3. Improve UI responsiveness with useTransition: create a search form that filters the list as you type. Use useTransition to update the list while keeping the input responsive. Also add a "Searching..." display using isPending.

Hint
  1. In the DevTools Profiler tab, record in the order "Record" → interact with the app → "Stop". You can enable "Highlight updates" from "Settings".
  2. Do not forget to include the filter condition and sort condition in useMemo's second argument (the dependency array).
  3. What you wrap with startTransition is the list state update. Place the input value update outside of startTransition.
Answer and explanation

Challenge 1: Use the React Developer Tools Profiler

After installing the React Developer Tools, open DevTools and select the "Profiler" tab.

  1. Click the gear icon (settings)
  2. In the "General" tab, check "Highlight updates when components render"
  3. Click the "Record" button to start recording
  4. Interact with the app, such as clicking a button
  5. End the recording with the "Stop" button

In the recorded results, you can confirm each component's rendering time and "Why did this render?" (why it re-rendered). When a parent component's state changes, you can visually see that the child components also re-render.


Challenge 2: Memoize a computation with useMemo

import { useState, useMemo } from 'react'

// 1000 items of dummy data
const items = Array.from({ length: 1000 }, (_, i) => ({
id: i,
name: `Item ${i}`,
category: ['A', 'B', 'C'][i % 3]
}))

function ItemList() {
const [filter, setFilter] = useState('')
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc')
const [count, setCount] = useState(0)

// Without useMemo: recomputed every time count changes
// const filteredItems = items
// .filter(item => item.name.toLowerCase().includes(filter.toLowerCase()))
// .sort((a, b) => sortOrder === 'asc'
// ? a.name.localeCompare(b.name)
// : b.name.localeCompare(a.name))

// With useMemo: recomputed only when filter/sortOrder changes
const filteredItems = useMemo(() => {
console.log('Running filter and sort')
return items
.filter(item => item.name.toLowerCase().includes(filter.toLowerCase()))
.sort((a, b) => sortOrder === 'asc'
? a.name.localeCompare(b.name)
: b.name.localeCompare(a.name))
}, [filter, sortOrder])

return (
<div>
<input
value={filter}
onChange={e => setFilter(e.target.value)}
placeholder="Search..."
/>
<button onClick={() => setSortOrder(s => s === 'asc' ? 'desc' : 'asc')}>
{sortOrder === 'asc' ? 'Ascending' : 'Descending'}
</button>
<button onClick={() => setCount(c => c + 1)}>
Count: {count}
</button>
<ul>
{filteredItems.slice(0, 20).map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
)
}

Without useMemo, console.log is printed every time you press the count button. With useMemo, pressing the count button does not print console.log, and recomputation happens only when you change the filter condition or sort order.


Challenge 3: Improve UI responsiveness with useTransition

import { useState, useTransition, useMemo } from 'react'

// A large amount of dummy data
const allItems = Array.from({ length: 10000 }, (_, i) => `Item ${i + 1}`)

function SearchWithTransition() {
const [query, setQuery] = useState('')
const [searchQuery, setSearchQuery] = useState('')
const [isPending, startTransition] = useTransition()

const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value
setQuery(value) // the input is reflected immediately (high priority)

startTransition(() => {
// the list filtering is low priority
setSearchQuery(value)
})
}

const filteredItems = useMemo(() => {
return allItems.filter(item =>
item.toLowerCase().includes(searchQuery.toLowerCase())
)
}, [searchQuery])

return (
<div>
<input
value={query}
onChange={handleChange}
placeholder="Search..."
className="px-3 py-2 border rounded"
/>
{isPending && <span className="ml-2 text-gray-500">Searching...</span>}

<ul className="mt-4" style={{ opacity: isPending ? 0.7 : 1 }}>
{filteredItems.slice(0, 100).map((item, index) => (
<li key={index} className="py-1">{item}</li>
))}
</ul>
<p className="text-sm text-gray-500">
Showing {Math.min(100, filteredItems.length)} of {filteredItems.length}
</p>
</div>
)
}

By using useTransition, typing into the input field is reflected immediately, while the heavy list filtering is processed at low priority. While isPending is true, "Searching..." is displayed, and lowering the list's opacity tells the user that processing is in progress.

Summary

  • Understand how React re-rendering works
  • If the React Compiler is enabled, most of useMemo/useCallback/React.memo is automated. Manual memoization complements the cases where you cannot use the Compiler
  • Prevent unnecessary re-renders with React.memo
  • Memoize expensive computations with useMemo
  • Stabilize function references with useCallback
  • Improve UI responsiveness with useTransition/useDeferredValue
  • Avoid over-optimization and respond based on measurement
  • Measure with the React Developer Tools Profiler

In the next chapter, you will learn in more detail about debugging techniques and the React Developer Tools.