React Performance — memo, useMemo, useCallback, and Profiler
Advertisement
Introduction
Why This Matters
Premature optimization is a genuine problem in React codebases. Developers add React.memo, useMemo, and useCallback everywhere, which paradoxically makes apps slower by adding comparison overhead to cheap components. This guide teaches you to measure first, then optimize only where it counts.
How React Re-Renders Work
A component re-renders when its state changes, its parent re-renders, or its context value changes. Re-renders are not inherently bad — React's virtual DOM diffing is fast. The problem arises when:
- A component's render computation is genuinely expensive (large list sorting, complex math)
- A re-render causes a child to re-render needlessly thousands of times per second
React.memo — Skip Re-Renders for Unchanged Props
React.memo wraps a component and skips re-rendering if its props have not changed (shallow comparison).
import { memo } from 'react'
type PostCardProps = {
id: string
title: string
excerpt: string
publishedAt: string
}
// Without memo: re-renders every time the parent re-renders, even if props are identical
// With memo: only re-renders when id, title, excerpt, or publishedAt changes
const PostCard = memo(function PostCard({ id, title, excerpt, publishedAt }: PostCardProps) {
return (
<article className="border rounded-lg p-4 hover:shadow-md transition-shadow">
<h2 className="font-semibold">{title}</h2>
<p className="text-gray-600 text-sm mt-1">{excerpt}</p>
<time className="text-xs text-gray-400">{publishedAt}</time>
</article>
)
})
// Use with custom comparison for complex props
const ExpensiveTable = memo(
function ExpensiveTable({ rows, columns }: TableProps) {
return <table>{/* ... */}</table>
},
(prev, next) => {
// Only re-render if row count or column count changes
return prev.rows.length === next.rows.length && prev.columns.length === next.columns.length
}
)useCallback — Stable Function Identity
Without useCallback, a new function is created on every render. This breaks memo on children that receive the function as a prop.
'use client'
import { useState, useCallback, memo } from 'react'
const FilterButton = memo(function FilterButton({
label,
active,
onToggle
}: {
label: string
active: boolean
onToggle: (label: string) => void
}) {
console.log(`Rendering FilterButton: ${label}`) // Track renders
return (
<button
onClick={() => onToggle(label)}
className={`px-3 py-1 rounded-full text-sm ${active ? 'bg-blue-600 text-white' : 'bg-gray-100'}`}
>
{label}
</button>
)
})
export function FilterBar({ onFilterChange }: { onFilterChange: (filters: string[]) => void }) {
const [activeFilters, setActiveFilters] = useState<string[]>([])
// Without useCallback: new function every render → FilterButton always re-renders
// With useCallback: same function reference → FilterButton only re-renders when activeFilters changes
const handleToggle = useCallback((label: string) => {
setActiveFilters(prev => {
const next = prev.includes(label)
? prev.filter(f => f !== label)
: [...prev, label]
onFilterChange(next)
return next
})
}, [onFilterChange])
const FILTERS = ['React', 'TypeScript', 'Next.js', 'Tailwind']
return (
<div className="flex gap-2 flex-wrap">
{FILTERS.map(filter => (
<FilterButton
key={filter}
label={filter}
active={activeFilters.includes(filter)}
onToggle={handleToggle}
/>
))}
</div>
)
}useMemo — Cache Expensive Calculations
Only use useMemo when the computation is genuinely expensive and runs on every render.
'use client'
import { useMemo, useState } from 'react'
type Transaction = { id: string; amount: number; category: string; date: string }
export function TransactionSummary({ transactions }: { transactions: Transaction[] }) {
const [selectedCategory, setSelectedCategory] = useState<string | null>(null)
// This runs on every render without useMemo — fine for small arrays
// With useMemo: recalculates only when transactions or selectedCategory changes
const summary = useMemo(() => {
const filtered = selectedCategory
? transactions.filter(t => t.category === selectedCategory)
: transactions
return {
total: filtered.reduce((sum, t) => sum + t.amount, 0),
average: filtered.length ? filtered.reduce((sum, t) => sum + t.amount, 0) / filtered.length : 0,
byCategory: Object.groupBy(filtered, t => t.category)
}
}, [transactions, selectedCategory])
const categories = useMemo(
() => [...new Set(transactions.map(t => t.category))],
[transactions]
)
return (
<div>
<div className="flex gap-2 mb-4">
<button onClick={() => setSelectedCategory(null)} className={!selectedCategory ? 'font-bold' : ''}>All</button>
{categories.map(cat => (
<button key={cat} onClick={() => setSelectedCategory(cat)} className={selectedCategory === cat ? 'font-bold' : ''}>{cat}</button>
))}
</div>
<p>Total: ${summary.total.toFixed(2)}</p>
<p>Average: ${summary.average.toFixed(2)}</p>
</div>
)
}Using the React Profiler
The React DevTools Profiler is the authoritative tool for diagnosing performance. Record a session, then look for:
- Commit bars — taller bars indicate slower renders
- Why did this render? — shows which prop or state change triggered the render
- Self time — how long the component itself took, excluding children
Only apply optimizations to components with high self time or frequent unnecessary renders.
Common Mistakes
- Wrapping every component in
memo— comparison overhead adds up for cheap components - Using
useMemofor simple operations:const doubled = useMemo(() => count * 2, [count])— just writecount * 2 - Applying
useCallbackto event handlers that are not passed to memoized children - Using
[]as the dependency array foruseCallbackwhen the callback references state or props — use ESLint react-hooks/exhaustive-deps
Best Practices
- Profile first — use React DevTools Profiler to identify slow renders before adding memoization
- Virtualize long lists with
@tanstack/react-virtualorreact-windowinstead of rendering thousands of DOM nodes - Move state down to the smallest component that needs it to minimize re-render scope
- Use
keyprop strategically to reset component state instead of complex synchronization logic
Key Takeaways
- React re-renders are fast — optimize only after profiling shows a real problem
React.memoskips re-renders when props have not changed using shallow comparisonuseCallbackstabilizes function references so memoized children do not re-render unnecessarilyuseMemocaches expensive computations — only worthwhile when the computation takes measurable time- All three memoization tools add overhead; they only improve performance when the savings exceed the comparison cost
- The React DevTools Profiler shows which components re-render and why — always profile before optimizing
- Virtualizing long lists (1,000+ items) provides far more benefit than any memoization strategy
- Moving state down the component tree reduces the blast radius of state changes
Advertisement