React Performance — memo, useMemo, useCallback, and Profiler

Sanjeev SharmaSanjeev Sharma
5 min read

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:

  1. A component's render computation is genuinely expensive (large list sorting, complex math)
  2. 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 useMemo for simple operations: const doubled = useMemo(() => count * 2, [count]) — just write count * 2
  • Applying useCallback to event handlers that are not passed to memoized children
  • Using [] as the dependency array for useCallback when 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-virtual or react-window instead of rendering thousands of DOM nodes
  • Move state down to the smallest component that needs it to minimize re-render scope
  • Use key prop 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.memo skips re-renders when props have not changed using shallow comparison
  • useCallback stabilizes function references so memoized children do not re-render unnecessarily
  • useMemo caches 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

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading