The Right Mental Model for React Performance
Most React performance problems are architectural problems, not framework limitations. Before reaching for useMemo or React.memo, understand why React re-renders.
React re-renders a component when:
- Its state changes (
useState,useReducer) - Its parent re-renders and passes new props
- A context it subscribes to changes
The goal is not to eliminate re-renders — it is to eliminate unnecessary re-renders, and ensure that necessary re-renders are fast.
Measuring Before Optimizing
import { Profiler, ProfilerOnRenderCallback } from 'react'
const onRender: ProfilerOnRenderCallback = (
id,
phase,
actualDuration,
baseDuration,
) => {
if (actualDuration > 16) {
console.warn(`Slow render: ${id} took ${actualDuration.toFixed(2)}ms`)
}
}
function App() {
return (
<Profiler id="ProductList" onRender={onRender}>
<ProductList />
</Profiler>
)
}
Open React DevTools Profiler, record an interaction, and look for:
- Components rendering more often than expected
- Render durations exceeding 16ms (one frame at 60fps)
React.memo: When It Helps
React.memo prevents a component from re-rendering if its props have not changed:
// Without memo: re-renders every time parent re-renders
function ExpensiveChart({ data, title }: ChartProps) { ... }
// With memo: only re-renders when data or title actually change
const ExpensiveChart = React.memo(function ExpensiveChart({ data, title }: ChartProps) { ... })
The reference equality trap:
// Bad: memo is useless — options is a new object every render
function Parent() {
return <Chart options={{ color: 'blue', width: 300 }} />
}
// Good: move constant objects outside the component
const CHART_OPTIONS = { color: 'blue', width: 300 }
function Parent() {
return <Chart options={CHART_OPTIONS} />
}
useMemo: Caching Expensive Computations
import { useMemo } from 'react'
function ProductList({ products, filters, sortBy }: Props) {
// Bad: recalculated on every render, even unrelated state changes
const filteredProducts = products
.filter(p => filters.every(f => f(p)))
.sort((a, b) => compareFn(a, b, sortBy))
// Good: only recalculates when inputs change
const filteredProducts = useMemo(
() => products
.filter(p => filters.every(f => f(p)))
.sort((a, b) => compareFn(a, b, sortBy)),
[products, filters, sortBy]
)
return <ul>{filteredProducts.map(p => <ProductItem key={p.id} product={p} />)}</ul>
}
Stable context values:
function UserProfile({ userId }: Props) {
const [theme, setTheme] = useState('light')
// Bad: new context value object every render, all consumers re-render
// return <UserContext.Provider value={{ userId, theme, setTheme }}>
// Good: only creates new object when userId or theme changes
const contextValue = useMemo(
() => ({ userId, theme, setTheme }),
[userId, theme]
)
return (
<UserContext.Provider value={contextValue}>
<UserDetails />
</UserContext.Provider>
)
}
useCallback: Stable Function References
import { useCallback, memo } from 'react'
const ExpensiveChild = memo(function ExpensiveChild({
onAction,
}: {
onAction: (id: string) => void
}) {
return <div>...</div>
})
function Parent() {
const [count, setCount] = useState(0)
// Bad: new function reference every render — breaks memo on ExpensiveChild
// const handleAction = (id: string) => { ... }
// Good: stable reference — memo on ExpensiveChild works as intended
const handleAction = useCallback((id: string) => {
analytics.track('action', { id })
}, [])
return (
<>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
<ExpensiveChild onAction={handleAction} />
</>
)
}
Rule: useCallback is primarily useful when passing callbacks to memoized child components.
useTransition: Keeping the UI Responsive
React Concurrent Mode lets you mark state updates as non-urgent. The UI stays responsive while React processes expensive updates in the background:
import { useState, useTransition } from 'react'
function SearchPage() {
const [query, setQuery] = useState('')
const [isPending, startTransition] = useTransition()
const [results, setResults] = useState<SearchResult[]>([])
const handleSearch = (value: string) => {
// Immediate: update input (urgent — user is typing)
setQuery(value)
// Deferred: run expensive search (non-urgent — can wait)
startTransition(() => {
const newResults = searchIndex.query(value)
setResults(newResults)
})
}
return (
<div>
<input value={query} onChange={e => handleSearch(e.target.value)} />
{isPending && <Spinner />}
<ResultsList results={results} />
</div>
)
}
useDeferredValue — when you do not control the state update:
function SearchResults({ query }: { query: string }) {
const deferredQuery = useDeferredValue(query)
const results = useMemo(() => searchIndex.query(deferredQuery), [deferredQuery])
return (
<div style={{ opacity: query !== deferredQuery ? 0.7 : 1 }}>
{results.map(r => <ResultItem key={r.id} result={r} />)}
</div>
)
}
Virtual Lists: Handling Large Data
Rendering 10,000 list items crashes browsers. Virtual lists only render what is visible:
import { useVirtualizer } from '@tanstack/react-virtual'
import { useRef } from 'react'
function VirtualUserList({ users }: { users: User[] }) {
const parentRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: users.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 72,
overscan: 5,
})
return (
<div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map(virtualRow => (
<div
key={virtualRow.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualRow.start}px)`,
}}
>
<UserRow user={users[virtualRow.index]} />
</div>
))}
</div>
</div>
)
}
With virtualization, rendering 100,000 items is as fast as rendering 20.
State Colocation: The Architectural Fix
Lifting state too high causes too many re-renders:
// Bad: search state in App re-renders the entire tree on every keystroke
function App() {
const [searchQuery, setSearchQuery] = useState('')
return (
<div>
<Header /> {/* Re-renders needlessly */}
<Sidebar /> {/* Re-renders needlessly */}
<SearchBar query={searchQuery} onChange={setSearchQuery} />
<SearchResults query={searchQuery} />
</div>
)
}
// Good: colocate state with the components that need it
function App() {
return (
<div>
<Header />
<Sidebar />
<SearchSection /> {/* Contains its own state */}
</div>
)
}
function SearchSection() {
const [searchQuery, setSearchQuery] = useState('')
return (
<>
<SearchBar query={searchQuery} onChange={setSearchQuery} />
<SearchResults query={searchQuery} />
</>
)
}
Context Performance Pitfalls
// Bad: every consumer re-renders when user OR theme changes
const AppContext = createContext<AppState>(defaultState)
// Good: split contexts — user consumers don't re-render on theme changes
const UserContext = createContext<UserContextValue>(defaultUser)
const ThemeContext = createContext<ThemeContextValue>(defaultTheme)
Performance Checklist
- Profile first — identify the slow component in React DevTools Profiler
- Check render frequency — is it rendering more often than expected?
- Check render duration — is this render inherently slow?
- For high frequency:
React.memo+useCallback/useMemofor stable references - For slow renders: virtualize long lists, use
useMemofor expensive computation,useTransitionfor non-urgent updates - For architectural issues: colocate state, split contexts
Measure, identify the specific bottleneck, then apply the targeted fix.