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
This chapter uses the project created in Chapter 3. Start the dev server with npm run dev and learn while writing code.
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.
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.
create-vite 9 provides a "TypeScript + React Compiler" template. For a new project, just selecting this template at scaffold time enables the React Compiler.
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
- A change in State
- A change in Props
- A re-render of the parent component
- 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
}
)
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
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.
- Open DevTools and select the "Profiler" tab
- Open the ⚙️ (Settings) icon and check "Record why each component rendered while profiling"
- Click the "Record" button
- Interact with the app
- 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.
| Cause | Example display | Response |
|---|---|---|
| The parent re-rendered | This component rendered because its parent re-rendered | Check 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 State | Hook 1 changed | Check whether the update is actually needed. Avoid unnecessary setState |
| A change in Context | Context changed | Split 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 |
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
- Is optimization actually needed? - address it after a problem occurs
- Measure with the React Developer Tools - based on measurement, not guesswork
- Identify unnecessary re-renders - confirm with the Profiler
- Check whether you can enable the React Compiler - if enabled, most manual memoization is unnecessary
- Use
React.memoappropriately - only for expensive components - Use
useMemo/useCallbackappropriately - set the dependency array correctly - Virtualize large lists - consider it for 1000+ items
- Code splitting - keep the initial bundle small
Try it
Take on the following challenges to deepen your understanding of performance optimization.
-
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.
-
Memoize a computation with useMemo: create a component that filters and sorts a list of 1000 items, and compare the number of
console.logoutputs with and withoutuseMemo. -
Improve UI responsiveness with useTransition: create a search form that filters the list as you type. Use
useTransitionto update the list while keeping the input responsive. Also add a "Searching..." display usingisPending.
Hint
- In the DevTools Profiler tab, record in the order "Record" → interact with the app → "Stop". You can enable "Highlight updates" from "Settings".
- Do not forget to include the filter condition and sort condition in
useMemo's second argument (the dependency array). - What you wrap with
startTransitionis the list state update. Place the input value update outside ofstartTransition.
Answer and explanation
Challenge 1: Use the React Developer Tools Profiler
After installing the React Developer Tools, open DevTools and select the "Profiler" tab.
- Click the gear icon (settings)
- In the "General" tab, check "Highlight updates when components render"
- Click the "Record" button to start recording
- Interact with the app, such as clicking a button
- 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.memois 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.