ReactPerformance Optimization Techniques

Performance Optimization Techniques

React is fast by default. But as applications grow, specific patterns can create bottlenecks. This page walks through a prioritised checklist of optimisation techniques — each with a code example showing the before and the fix.

Warning
Always profile first. Applying optimisations without measurement adds code complexity and cognitive overhead without a guaranteed payoff. Open the React DevTools Profiler, record the slow interaction, and identify the actual bottleneck before touching a line of code.
1. Profile First — Never Guess

The golden rule of performance work is: measure, don't guess. Use React DevTools Profiler to record the interaction, check the Ranked view for the slowest components, and check "why did this render?" before applying any fix. See the Profiling page for the full workflow.

2. React.memo for Stable-Prop Components

Wrap a component in React.memo to skip re-rendering when its props have not changed (shallow equality). Best applied to components that receive primitive or stable-reference props and render frequently due to parent re-renders.

JSX
import { memo } from 'react'

// Without memo — re-renders every time parent re-renders
function UserCard({ user }) {
  return <div>{user.name} — {user.role}</div>
}

// With memo — skips re-render when user reference hasn't changed
const UserCard = memo(function UserCard({ user }) {
  return <div>{user.name} — {user.role}</div>
})

// memo only helps when the parent renders often AND props are stable
// If the parent always passes a new object: memo({ name, role }) breaks memo
// Solution: pass primitive props or use useMemo on the parent side
3. useMemo for Expensive Computations

useMemo caches the result of a computation between renders, recalculating only when the listed dependencies change. Use it when a computation is measurably slow (sort, filter, aggregate over large arrays).

JSX
import { useMemo } from 'react'

function OrderSummary({ orders, filter }) {
  // BAD — runs on every re-render, even unrelated state changes
  const filtered = orders
    .filter(o => o.status === filter)
    .sort((a, b) => b.total - a.total)

  // GOOD — only recalculates when orders or filter changes
  const filtered = useMemo(
    () =>
      orders
        .filter(o => o.status === filter)
        .sort((a, b) => b.total - a.total),
    [orders, filter]
  )

  return filtered.map(o => <OrderRow key={o.id} order={o} />)
}
Note
useMemo has a cost — React must store the cached value and run equality checks on dependencies every render. Only use it when the computation itself is measurably slow (use Chrome Performance tab to time it).
4. useCallback for Function Props

Without useCallback, a new function reference is created on every render. This breaks React.memo on children that receive the function as a prop — the prop technically "changed" (new reference), so memo re-renders anyway.

JSX
import { useCallback, memo } from 'react'

function Parent({ userId }) {
  // BAD — new function reference on every render
  const handleDelete = (id) => deleteUser(id)

  // GOOD — same reference across renders (as long as userId doesn't change)
  const handleDelete = useCallback((id) => deleteUser(id), [])

  // UserRow is memoized — useCallback ensures it doesn't re-render needlessly
  return <UserRow onDelete={handleDelete} />
}

const UserRow = memo(function UserRow({ user, onDelete }) {
  return (
    <div>
      {user.name}
      <button onClick={() => onDelete(user.id)}>Delete</button>
    </div>
  )
})
5. Code Splitting for Bundle Size

Large JavaScript bundles slow down initial load. Code splitting with React.lazy and Suspense delays loading a component until it is actually needed.

JSX
import { lazy, Suspense } from 'react'

// The AdminDashboard chunk is NOT included in the initial bundle
// It is downloaded only when the user navigates to /admin
const AdminDashboard = lazy(() => import('./AdminDashboard'))

function App() {
  return (
    <Suspense fallback={<LoadingSpinner />}>
      <Routes>
        <Route path="/" element={<HomePage />} />
        <Route path="/admin" element={<AdminDashboard />} />
      </Routes>
    </Suspense>
  )
}

// Also split heavy libraries:
const RichTextEditor = lazy(() => import('./RichTextEditor'))
// This keeps draft-js / tiptap out of the initial bundle
6. Virtualization for Long Lists

Rendering 1,000 rows creates 1,000 real DOM nodes. Virtualization (windowing) renders only the visible rows — typically 10–30. See the List Virtualization page for full details, but the gist:

JSX
import { FixedSizeList } from 'react-window'

// Without virtualization: 10,000 DOM nodes
function NaiveList({ items }) {
  return items.map(item => <Row key={item.id} item={item} />)
}

// With virtualization: only ~20 DOM nodes at any time
function VirtualList({ items }) {
  return (
    <FixedSizeList
      height={600}
      width="100%"
      itemCount={items.length}
      itemSize={48}
    >
      {({ index, style }) => (
        <div style={style}>
          <Row item={items[index]} />
        </div>
      )}
    </FixedSizeList>
  )
}
7. Debounce and Throttle for Frequent Events

Events like onChange, onMouseMove, and onScroll can fire dozens of times per second. Debouncing delays processing until the user pauses; throttling limits processing to at most once per interval.

JSX
import { useState, useCallback } from 'react'

// Simple debounce helper (or use lodash.debounce)
function debounce(fn, delay) {
  let timer
  return (...args) => {
    clearTimeout(timer)
    timer = setTimeout(() => fn(...args), delay)
  }
}

function SearchBox({ onSearch }) {
  const [value, setValue] = useState('')

  // Controlled input updates instantly (no lag for the user)
  // But the expensive search fires only after 300ms of no typing
  const debouncedSearch = useCallback(debounce(onSearch, 300), [onSearch])

  return (
    <input
      value={value}
      onChange={e => {
        setValue(e.target.value)      // immediate — updates the input
        debouncedSearch(e.target.value)  // debounced — triggers API call
      }}
    />
  )
}
8. Avoid Anonymous Objects and Arrays in Render

Inline objects and arrays create new references on every render, breaking React.memo and adding pressure to dependency arrays.

JSX
// BAD — new style object created every render
// If Child is memoized, it re-renders every time Parent does
function Parent() {
  return <Child style={{ color: 'red', fontSize: 16 }} options={['a', 'b']} />
}

// GOOD — define stable references outside the component
const CHILD_STYLE = { color: 'red', fontSize: 16 }
const CHILD_OPTIONS = ['a', 'b']

function Parent() {
  return <Child style={CHILD_STYLE} options={CHILD_OPTIONS} />
}

// For dynamic values that depend on props/state, use useMemo:
function Parent({ color }) {
  const style = useMemo(() => ({ color, fontSize: 16 }), [color])
  return <Child style={style} />
}
9. Key Placement for Stable Identity

Keys are not just for lists. A key on any component tells React "this is a new instance when the key changes, preserve the instance when the key stays the same". Place keys intentionally to avoid surprising unmounts.

JSX
// Use key to reset form state when switching records
// Without key, form retains the previous user's values
function UserEditor({ selectedUserId }) {
  return <UserForm key={selectedUserId} userId={selectedUserId} />
}

// Use stable keys on list items — never use array index for sorted/filtered lists
function ProductList({ products }) {
  return products.map(p => (
    <ProductCard key={p.id} product={p} />  // stable id, not index
  ))
}
  • Profile first — find the actual bottleneck with DevTools before writing any optimisation code

  • React.memo — wrap stable components that receive stable props and render due to parent

  • useMemo — cache expensive computations with measurable cost (not trivial ones)

  • useCallback — stabilise function props passed to memoized children

  • Code splitting — lazy-load routes and heavy components

  • Virtualization — render only visible rows for lists with 1,000+ items

  • Debounce/throttle — limit processing for high-frequency events

  • Stable references — hoist constant objects/arrays outside render

  • Keys — use stable IDs, use key to force reset, avoid index keys on dynamic lists

Tip
Apply one optimisation at a time and measure the impact after each. Stacking multiple optimisations at once makes it impossible to know which one actually helped — or whether any of them did.