React Performance Guide (Part 1)
Optimize React renders — re-render triggers, React.memo, useCallback, useMemo, derived state, debouncing and throttling hooks, and a preview of virtualization, Context splits, and React Compiler.
Introduction
React re-renders are cheap until they are not. Large lists, unstable props, derived state stored in useEffect, and search inputs firing API calls on every keystroke are where performance problems appear. This Performance Guide (Part 1) maps the foundational patterns: understand what triggers renders, apply memoization deliberately, compute derived state in render, and debounce/throttle high-frequency handlers.
This guide covers:
- Re-rendering triggers — state, props, Context, parent renders, Strict Mode
- Memoization —
React.memo,useCallback,useMemo - Derived state patterns — compute in render, avoid sync effects
- Debouncing —
useDebouncefor search and API calls - Throttling — rate-limit scroll and resize handlers
- Advanced techniques (Part 2 preview) — splitting, virtualization, Context optimization, React Compiler
Quick index
| # | Section |
|---|---|
| 1 | Re-rendering triggers |
| 2 | Memoization |
| 3 | Derived state patterns |
| 4 | Debouncing |
| 5 | Throttling |
| 6 | Advanced techniques (Part 2 preview) |
| 7 | Best practices & checklist |
1. Re-rendering triggers
A component re-renders when React detects that its output might have changed. Know the triggers before optimizing.
| Trigger | What happens |
|---|---|
| State changes | useState, useReducer dispatch updates local state |
| Prop changes | Parent passes new prop values (reference or primitive) |
| Context value changes | Any consumer re-renders when Provider value changes |
| Parent re-renders | Default: child re-renders even if its props are identical |
| Strict Mode (dev only) | React double-invokes renders to surface impure side effects |
Parent state changes
→ Parent re-renders
→ All children re-render (unless memo + stable props)
→ Context consumers re-render if value reference changedMinimal reproduction
'use client'
import { useState } from 'react'
function Child({ label }: { label: string }) {
console.log('Child render:', label)
return <p>{label}</p>
}
function Parent() {
const [count, setCount] = useState(0)
return (
<div>
<button type="button" onClick={() => setCount((c) => c + 1)}>
Count: {count}
</button>
{/* Child re-renders on every count click — props unchanged */}
<Child label="Static" />
</div>
)
}2. Memoization
Memoization caches results so React skips redundant work. Three tools cover components, functions, and values.
| Tool | Caches | Skips when |
|---|---|---|
React.memo | Component output | Props are shallow-equal to previous render |
useCallback | Function reference | Dependencies unchanged |
useMemo | Computed value | Dependencies unchanged |
React.memo — HOC for components
memo is a higher-order function — it wraps a component and bails out when props are unchanged.
import { memo } from 'react'
type RowProps = { label: string; value: number }
const ExpensiveRow = memo(function ExpensiveRow({ label, value }: RowProps) {
return (
<tr>
<td>{label}</td>
<td>{value}</td>
</tr>
)
})
function DataTable({ rows }: { rows: RowProps[] }) {
return (
<table>
<tbody>
{rows.map((r) => (
<ExpensiveRow key={r.label} {...r} />
))}
</tbody>
</table>
)
}useCallback — stable function references
Inline handlers create a new function every render, breaking memo on children.
'use client'
import { memo, useCallback, useState } from 'react'
const SearchResults = memo(function SearchResults({
query,
onSelect,
}: {
query: string
onSelect: (id: string) => void
}) {
// expensive filter…
return (
<ul>
<li>
<button type="button" onClick={() => onSelect('1')}>
Result for {query}
</button>
</li>
</ul>
)
})
function SearchPage() {
const [query, setQuery] = useState('')
const handleSelect = useCallback((id: string) => {
console.log('Selected', id)
}, [])
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<SearchResults query={query} onSelect={handleSelect} />
</>
)
}useMemo — cache expensive computation
'use client'
import { useMemo, useState } from 'react'
type Product = { id: string; name: string; price: number; category: string }
function ProductCatalog({ products, filter }: { products: Product[]; filter: string }) {
const filtered = useMemo(() => {
const lower = filter.toLowerCase()
return products.filter((p) => p.name.toLowerCase().includes(lower) || p.category.toLowerCase().includes(lower))
}, [products, filter])
const totalValue = useMemo(() => filtered.reduce((sum, p) => sum + p.price, 0), [filtered])
return (
<div>
<p>
{filtered.length} products — total ${totalValue}
</p>
<ul>
{filtered.map((p) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
</div>
)
}3. Derived state patterns
Derived state is a value computed from existing state or props — it should not live in its own useState synced via useEffect.
| Approach | Verdict |
|---|---|
Store derived value in useState | ❌ Anti-pattern — duplicate source of truth |
Sync with useEffect | ❌ Extra render cycle, easy to desync |
| Compute in render | ✅ Single source of truth, always consistent |
useMemo for expensive derive | ✅ When computation cost justifies caching |
Anti-pattern — derived state in useState + useEffect
'use client'
import { useEffect, useState } from 'react'
function OrderSummary({ items }: { items: { price: number; qty: number }[] }) {
const [total, setTotal] = useState(0)
// ❌ Sync effect — runs after paint, can flash wrong value
useEffect(() => {
setTotal(items.reduce((sum, i) => sum + i.price * i.qty, 0))
}, [items])
return <p>Total: ${total}</p>
}Correct — compute directly in render
function OrderSummary({ items }: { items: { price: number; qty: number }[] }) {
const total = items.reduce((sum, i) => sum + i.price * i.qty, 0)
return <p>Total: ${total}</p>
}Expensive derived value — useMemo
function UserDashboard({ users, roleFilter }: { users: User[]; roleFilter: string }) {
const admins = useMemo(
() => users.filter((u) => u.role === roleFilter).sort((a, b) => a.name.localeCompare(b.name)),
[users, roleFilter],
)
return (
<ul>
{admins.map((u) => (
<li key={u.id}>{u.name}</li>
))}
</ul>
)
}4. Debouncing
Debouncing delays execution until activity pauses — ideal when the user types faster than the server should respond.
keystrokes: r → re → rea → reac → react
debounced: · · · · ·────► API call (300ms after last key)useDebounce hook
'use client'
import { useEffect, useState } from 'react'
export function useDebounce<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState(value)
useEffect(() => {
const timeout = window.setTimeout(() => setDebounced(value), delay)
return () => window.clearTimeout(timeout)
}, [value, delay])
return debounced
}Search input with debounced API call
'use client'
import { useEffect, useState } from 'react'
import { useDebounce } from '@/hooks/useDebounce'
type SearchResult = { id: string; title: string }
async function searchApi(query: string): Promise<SearchResult[]> {
if (!query) return []
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`)
return res.json()
}
function BlogSearch() {
const [input, setInput] = useState('')
const [results, setResults] = useState<SearchResult[]>([])
const [loading, setLoading] = useState(false)
const debouncedQuery = useDebounce(input, 300)
useEffect(() => {
let cancelled = false
setLoading(true)
searchApi(debouncedQuery)
.then((data) => {
if (!cancelled) setResults(data)
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [debouncedQuery])
return (
<div>
<input value={input} onChange={(e) => setInput(e.target.value)} placeholder="Search posts…" aria-busy={loading} />
<ul>
{results.map((r) => (
<li key={r.id}>{r.title}</li>
))}
</ul>
</div>
)
}5. Throttling
Throttling ensures a function runs at most once per time window — ideal for high-frequency events where you need regular updates, not a final pause.
| Pattern | Fires when | Best for |
|---|---|---|
| Debounce | Activity stops | Search API, form validation |
| Throttle | At most once per interval | Scroll, resize, mousemove |
useThrottle hook (timestamp-based)
'use client'
import { useEffect, useRef, useState } from 'react'
export function useThrottle<T>(value: T, interval: number): T {
const [throttled, setThrottled] = useState(value)
const lastRan = useRef(Date.now())
useEffect(() => {
const now = Date.now()
const elapsed = now - lastRan.current
if (elapsed >= interval) {
lastRan.current = now
setThrottled(value)
return
}
const timeout = window.setTimeout(() => {
lastRan.current = Date.now()
setThrottled(value)
}, interval - elapsed)
return () => window.clearTimeout(timeout)
}, [value, interval])
return throttled
}Scroll position tracker
'use client'
import { useEffect, useState } from 'react'
function useScrollY(throttleMs = 100) {
const [scrollY, setScrollY] = useState(0)
useEffect(() => {
let ticking = false
let lastRan = 0
function onScroll() {
const now = Date.now()
if (!ticking) {
window.requestAnimationFrame(() => {
if (now - lastRan >= throttleMs) {
lastRan = now
setScrollY(window.scrollY)
}
ticking = false
})
ticking = true
}
}
window.addEventListener('scroll', onScroll, { passive: true })
return () => window.removeEventListener('scroll', onScroll)
}, [throttleMs])
return scrollY
}
function StickyHeader() {
const scrollY = useScrollY(100)
const compact = scrollY > 64
return (
<header className={compact ? 'h-12 shadow' : 'h-20'}>
<h1 className={compact ? 'text-sm' : 'text-xl'}>My App</h1>
</header>
)
}6. Advanced techniques (Part 2 preview)
Part 1 covers render discipline; Part 2 (coming next) goes deeper. Preview the four advanced areas now so you know where optimization scales.
| Technique | Problem it solves |
|---|---|
| Splitting components | Isolate state so only the changing subtree re-renders |
| Virtualization | Render only visible rows in 10k+ item lists |
| Context optimization | Prevent Provider value changes from re-rendering the whole app |
| React Compiler | Automatic memoization at build time (React 19+) |
Splitting components — isolate state
// ❌ One component — typing re-renders the heavy chart
function Dashboard() {
const [filter, setFilter] = useState('')
return (
<>
<input value={filter} onChange={(e) => setFilter(e.target.value)} />
<ExpensiveChart filter={filter} />
</>
)
}
// ✅ Split — input state lives in child; chart sibling skips re-render on unrelated parent updates
function Dashboard() {
return (
<>
<FilterInput />
<ExpensiveChartWrapper />
</>
)
}Virtualization — @tanstack/react-virtual
'use client'
import { useRef } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
function VirtualList({ items }: { items: string[] }) {
const parentRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 48,
})
return (
<div ref={parentRef} style={{ height: 400, overflow: 'auto' }}>
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map((row) => (
<div
key={row.key}
style={{
position: 'absolute',
top: row.start,
height: row.size,
width: '100%',
}}>
{items[row.index]}
</div>
))}
</div>
</div>
)
}Context optimization — split providers
// ❌ One context — theme change re-renders cart consumers
const AppContext = createContext({ theme: 'light', cart: [] })
// ✅ Split — theme subscribers ignore cart updates
<ThemeProvider>
<CartProvider>{children}</CartProvider>
</ThemeProvider>See Provider Pattern for split state/actions contexts and memoized values.
React Compiler
React 19's React Compiler (formerly React Forget) analyzes components at build time and inserts memoization automatically — reducing manual memo / useMemo / useCallback boilerplate when enabled in your toolchain.
7. Best practices & checklist
| Mistake | Fix |
|---|---|
| Memo every component | Profile first; memo expensive leaves only |
useMemo on trivial math | Compute in render |
Derived state in useEffect | Derive in render or useMemo |
| Inline objects/functions to memo children | useMemo / useCallback or split components |
| Debounce without cleanup | clearTimeout in effect return |
Throttle scroll without passive | Add { passive: true } to listeners |
| Context with one fat value object | Split contexts by update frequency |
Summary
| Layer | Part 1 tool |
|---|---|
| Understand | Re-render triggers |
| Skip work | memo, useCallback, useMemo |
| Stay correct | Derived state in render |
| Rate-limit | Debounce (pause) / throttle (interval) |
| Scale (Part 2) | Split, virtualize, Context, Compiler |
Performance is a practice, not a one-time refactor. Measure renders, fix the highest-cost trigger, and reach for advanced techniques only when Part 1 tools are insufficient.
Next reads: Provider Pattern for Context optimization, Optimistic UI Pattern for transition-based updates, and React Design Patterns for the full pattern roadmap.
Subscribe to my newsletter
Stay up to date and get notified when I share new contents.
No spam ever, unsubscribe anytime