Performance Optimization Techniques
React is fast by default. But as applications grow, specific patterns can create bottlenecks. This page walks through a prioritised checklist of optimisation techniques — each with a code example showing the before and the fix.
1. Profile First — Never Guess
The golden rule of performance work is: measure, don't guess. Use React DevTools Profiler to record the interaction, check the Ranked view for the slowest components, and check "why did this render?" before applying any fix. See the Profiling page for the full workflow.
2. React.memo for Stable-Prop Components
Wrap a component in React.memo to skip re-rendering when its props have not changed (shallow equality). Best applied to components that receive primitive or stable-reference props and render frequently due to parent re-renders.
import { memo } from 'react'
// Without memo — re-renders every time parent re-renders
function UserCard({ user }) {
return <div>{user.name} — {user.role}</div>
}
// With memo — skips re-render when user reference hasn't changed
const UserCard = memo(function UserCard({ user }) {
return <div>{user.name} — {user.role}</div>
})
// memo only helps when the parent renders often AND props are stable
// If the parent always passes a new object: memo({ name, role }) breaks memo
// Solution: pass primitive props or use useMemo on the parent side3. useMemo for Expensive Computations
useMemo caches the result of a computation between renders, recalculating only when the listed dependencies change. Use it when a computation is measurably slow (sort, filter, aggregate over large arrays).
import { useMemo } from 'react'
function OrderSummary({ orders, filter }) {
// BAD — runs on every re-render, even unrelated state changes
const filtered = orders
.filter(o => o.status === filter)
.sort((a, b) => b.total - a.total)
// GOOD — only recalculates when orders or filter changes
const filtered = useMemo(
() =>
orders
.filter(o => o.status === filter)
.sort((a, b) => b.total - a.total),
[orders, filter]
)
return filtered.map(o => <OrderRow key={o.id} order={o} />)
}4. useCallback for Function Props
Without useCallback, a new function reference is created on every render. This breaks React.memo on children that receive the function as a prop — the prop technically "changed" (new reference), so memo re-renders anyway.
import { useCallback, memo } from 'react'
function Parent({ userId }) {
// BAD — new function reference on every render
const handleDelete = (id) => deleteUser(id)
// GOOD — same reference across renders (as long as userId doesn't change)
const handleDelete = useCallback((id) => deleteUser(id), [])
// UserRow is memoized — useCallback ensures it doesn't re-render needlessly
return <UserRow onDelete={handleDelete} />
}
const UserRow = memo(function UserRow({ user, onDelete }) {
return (
<div>
{user.name}
<button onClick={() => onDelete(user.id)}>Delete</button>
</div>
)
})5. Code Splitting for Bundle Size
Large JavaScript bundles slow down initial load. Code splitting with React.lazy and Suspense delays loading a component until it is actually needed.
import { lazy, Suspense } from 'react'
// The AdminDashboard chunk is NOT included in the initial bundle
// It is downloaded only when the user navigates to /admin
const AdminDashboard = lazy(() => import('./AdminDashboard'))
function App() {
return (
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/admin" element={<AdminDashboard />} />
</Routes>
</Suspense>
)
}
// Also split heavy libraries:
const RichTextEditor = lazy(() => import('./RichTextEditor'))
// This keeps draft-js / tiptap out of the initial bundle6. Virtualization for Long Lists
Rendering 1,000 rows creates 1,000 real DOM nodes. Virtualization (windowing) renders only the visible rows — typically 10–30. See the List Virtualization page for full details, but the gist:
import { FixedSizeList } from 'react-window'
// Without virtualization: 10,000 DOM nodes
function NaiveList({ items }) {
return items.map(item => <Row key={item.id} item={item} />)
}
// With virtualization: only ~20 DOM nodes at any time
function VirtualList({ items }) {
return (
<FixedSizeList
height={600}
width="100%"
itemCount={items.length}
itemSize={48}
>
{({ index, style }) => (
<div style={style}>
<Row item={items[index]} />
</div>
)}
</FixedSizeList>
)
}7. Debounce and Throttle for Frequent Events
Events like onChange, onMouseMove, and onScroll can fire dozens of times per second. Debouncing delays processing until the user pauses; throttling limits processing to at most once per interval.
import { useState, useCallback } from 'react'
// Simple debounce helper (or use lodash.debounce)
function debounce(fn, delay) {
let timer
return (...args) => {
clearTimeout(timer)
timer = setTimeout(() => fn(...args), delay)
}
}
function SearchBox({ onSearch }) {
const [value, setValue] = useState('')
// Controlled input updates instantly (no lag for the user)
// But the expensive search fires only after 300ms of no typing
const debouncedSearch = useCallback(debounce(onSearch, 300), [onSearch])
return (
<input
value={value}
onChange={e => {
setValue(e.target.value) // immediate — updates the input
debouncedSearch(e.target.value) // debounced — triggers API call
}}
/>
)
}8. Avoid Anonymous Objects and Arrays in Render
Inline objects and arrays create new references on every render, breaking React.memo and adding pressure to dependency arrays.
// BAD — new style object created every render
// If Child is memoized, it re-renders every time Parent does
function Parent() {
return <Child style={{ color: 'red', fontSize: 16 }} options={['a', 'b']} />
}
// GOOD — define stable references outside the component
const CHILD_STYLE = { color: 'red', fontSize: 16 }
const CHILD_OPTIONS = ['a', 'b']
function Parent() {
return <Child style={CHILD_STYLE} options={CHILD_OPTIONS} />
}
// For dynamic values that depend on props/state, use useMemo:
function Parent({ color }) {
const style = useMemo(() => ({ color, fontSize: 16 }), [color])
return <Child style={style} />
}9. Key Placement for Stable Identity
Keys are not just for lists. A key on any component tells React "this is a new instance when the key changes, preserve the instance when the key stays the same". Place keys intentionally to avoid surprising unmounts.
// Use key to reset form state when switching records
// Without key, form retains the previous user's values
function UserEditor({ selectedUserId }) {
return <UserForm key={selectedUserId} userId={selectedUserId} />
}
// Use stable keys on list items — never use array index for sorted/filtered lists
function ProductList({ products }) {
return products.map(p => (
<ProductCard key={p.id} product={p} /> // stable id, not index
))
}Profile first — find the actual bottleneck with DevTools before writing any optimisation code
React.memo — wrap stable components that receive stable props and render due to parent
useMemo — cache expensive computations with measurable cost (not trivial ones)
useCallback — stabilise function props passed to memoized children
Code splitting — lazy-load routes and heavy components
Virtualization — render only visible rows for lists with 1,000+ items
Debounce/throttle — limit processing for high-frequency events
Stable references — hoist constant objects/arrays outside render
Keys — use stable IDs, use key to force reset, avoid index keys on dynamic lists