Most React performance advice falls into one of two categories: either too theoretical to apply, or suggesting optimizations for problems you don't have. This guide focuses on the techniques that make a measurable difference in real applications, with clear explanations of when each one actually helps.
The golden rule before optimizing anything: measure first. Use React DevTools Profiler and your browser's Performance tab to find actual bottlenecks before applying any of these techniques.
React re-renders a component when:
Rule 3 is why performance issues emerge — a parent state update can cascade re-renders through dozens of components even when none of them need to update.
// Every time ParentComponent re-renders (e.g., typing in a search input),
// ExpensiveList and SomethingUnrelated BOTH re-render unnecessarily
function ParentComponent() {
const [search, setSearch] = useState('')
return (
<div>
<input value={search} onChange={e => setSearch(e.target.value)} />
<ExpensiveList /> {/* Re-renders on every keystroke */}
<SomethingUnrelated /> {/* Also re-renders on every keystroke */}
</div>
)
}React.memo wraps a component and skips re-rendering if props haven't changed (shallow comparison).
// Without memo — re-renders every time parent re-renders
function UserCard({ user }: { user: User }) {
return (
<div>
<img src={user.avatar} alt={user.name} />
<h3>{user.name}</h3>
</div>
)
}
// With memo — only re-renders when user prop actually changes
const UserCard = React.memo(function UserCard({ user }: { user: User }) {
return (
<div>
<img src={user.avatar} alt={user.name} />
<h3>{user.name}</h3>
</div>
)
})When React.memo helps: components that receive stable props but have expensive parents.
When it doesn't help: when props change on every render anyway, or when the component is cheap to render. Don't memoize everything — the overhead of the comparison can exceed the cost of the re-render.
The problem: when you pass a function as a prop, a new function is created on every render, which breaks React.memo:
// BAD: handleDelete is a new function on every render
// UserCard's memo won't help because props keep changing
function UserList({ users }: { users: User[] }) {
function handleDelete(id: string) {
// delete logic
}
return users.map(user => (
<UserCard key={user.id} user={user} onDelete={handleDelete} />
))
}// GOOD: useCallback preserves the same function reference
function UserList({ users }: { users: User[] }) {
const handleDelete = useCallback((id: string) => {
// delete logic
}, []) // Empty deps: function never changes
return users.map(user => (
<UserCard key={user.id} user={user} onDelete={handleDelete} />
))
}When useCallback helps: only when passing functions to memoized components. Without React.memo on the child, useCallback does nothing useful.
// Without useMemo — this filter runs on every render, even when data hasn't changed
function ProductList({ products, search }: Props) {
const filtered = products.filter(p =>
p.name.toLowerCase().includes(search.toLowerCase())
)
// ...
}
// With useMemo — only re-computes when products or search changes
function ProductList({ products, search }: Props) {
const filtered = useMemo(
() => products.filter(p => p.name.toLowerCase().includes(search.toLowerCase())),
[products, search]
)
// ...
}When useMemo helps: genuinely expensive computations (sorting thousands of items, complex data transformations). For simple filters on small arrays, useMemo adds overhead without benefit.
The mental model: does this computation take more than ~1ms? Profile it. If not, don't memoize.
Not all performance issues are re-render issues. Large bundle sizes cause slow initial loads. Code splitting lets you load components only when needed:
import { lazy, Suspense } from 'react'
// Only loads RichTextEditor when actually rendered
const RichTextEditor = lazy(() => import('./RichTextEditor'))
const Analytics = lazy(() => import('./Analytics'))
function PostEditor() {
const [showEditor, setShowEditor] = useState(false)
return (
<div>
<button onClick={() => setShowEditor(true)}>Open Editor</button>
{showEditor && (
<Suspense fallback={<div>Loading editor...</div>}>
<RichTextEditor />
</Suspense>
)}
</div>
)
}A rich text editor might add 200KB to your bundle. If most users never open it, that's 200KB downloaded for nothing.
Route-level code splitting is automatic in Next.js. For large optional components (modals, editors, complex charts), use lazy explicitly.
Rendering 10,000 list items creates 10,000 DOM nodes. The browser can't handle that efficiently. Virtualization renders only the items currently visible in the viewport:
import { useVirtualizer } from '@tanstack/react-virtual'
import { useRef } from 'react'
function VirtualizedList({ items }: { items: Item[] }) {
const parentRef = useRef<HTMLDivElement>(null)
const rowVirtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 60, // Estimated row height in px
})
return (
<div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
<div style={{ height: `${rowVirtualizer.getTotalSize()}px`, position: 'relative' }}>
{rowVirtualizer.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)`,
}}
>
<ItemRow item={items[virtualItem.index]} />
</div>
))}
</div>
</div>
)
}When to use: lists with more than ~100 items where scrolling performance is important.
Before reaching for memoization, ask: is the state in the right place?
// BAD: search state in parent causes full re-render on every keystroke
function Page() {
const [search, setSearch] = useState('') // State here
return (
<div>
<SearchInput value={search} onChange={setSearch} />
<ExpensiveProductGrid /> {/* Re-renders on every keystroke */}
</div>
)
}
// GOOD: move search state to SearchInput — ExpensiveProductGrid never re-renders
function SearchInput() {
const [search, setSearch] = useState('') // State lives here
return <input value={search} onChange={e => setSearch(e.target.value)} />
}
function Page() {
return (
<div>
<SearchInput />
<ExpensiveProductGrid /> {/* Never re-renders */}
</div>
)
}State colocation is often more effective than memoization and requires zero API knowledge.
1. Premature optimization. Memoizing every component before profiling. This adds complexity without benefit and can actually slow things down.
2. Forgetting dependency arrays. useMemo and useCallback with wrong dependencies cause stale values or defeat the purpose by running on every render.
3. Using state for derived data. If a value can be computed from existing state, compute it — don't store it separately:
// BAD: derived state
const [items, setItems] = useState([])
const [count, setCount] = useState(0) // Derived from items.length
// GOOD: compute it
const [items, setItems] = useState([])
const count = items.length // Always in sync, zero extra state4. Ignoring key prop. Keys help React identify which items changed. Wrong keys cause unnecessary unmount/remount cycles.
React.memo only helps when props are stable; combine with useCallback for function propsuseMemo is for genuinely expensive computations — not simple transformsReact.lazy