Performance Optimization

Understand why React re-renders, where bottlenecks come from, and which tools to reach for.

Why React re-renders

A component re-renders when its own state changes or when its parent re-renders. Re-renders cascade downward, never upward. Ping any node and watch.

Component tree

amber badge = re-rendered >1×

<GrandParent>
renders: 1
<Parent>
renders: 1
<Child>
renders: 1
<Leaf>
renders: 1

no state

Three causes of a re-render

Own state changed

Only the component that called setState re-renders.

Parent re-rendered

Every child re-renders by default — even with identical props.

Context value changed

Any component subscribed via useContext re-renders.

⚠ The bottleneck

A slow component high in the tree makes every child slow on every interaction. Ping GrandParent — all four nodes re-render. That is the cascading cost you need to control.

React.memo [skip unnecessary child re-renders]

memo wraps a component and skips re-rendering it if its props haven't changed. Click 'unrelated re-render' to trigger a parent state change without touching the child's prop.

<Parent> renders: 1

without memo

<RegularChild>renders: 1

prop: 0

⚠ Re-renders whenever parent does

function Child({ value }) {
  return <div>{value}</div>
}

// Re-renders on every parent render
// even when value hasn't changed

with React.memo

<MemoChild>renders: 1

prop: 0

✓ Skips re-render when prop unchanged

const Child = memo(function Child({
  value,
}) {
  return <div>{value}</div>
})

// Skips if props are shallowly equal

React.memo adds a prop comparison step before every render.
If the comparison itself costs more than the render, you've actually made performance worse.

useMemo [cache expensive computations]

useMemo re-runs the computation only when its dependencies change. Trigger an unrelated re-render — the right panel skips the work entirely.

28

without useMemo

fibonacci(28) = 317811

renders: 1computed: 1×7.2ms this render
// Recomputes on every render
const result = fibonacci(n)

// Unrelated state changes
// still run this expensive work

with useMemo

fibonacci(28) = 317811

renders: 1computed: 1×4.9ms this render
// Only recomputes when n changes
const result = useMemo(
  () => fibonacci(n),
  [n]
)

// Unrelated renders = free

Rule of thumb: useMemo is worth it for genuinely slow computations (large list transforms, heavy math). Wrapping cheap operations adds overhead without benefit - profile first.

useCallback [stable function references]

memo() does a shallow-equal check on every prop. A plain () => {} creates a brand-new object on every render, so the check always fails and the child always re-renders — even though the logic is identical. useCallback returns the same function reference until its dependencies change, letting memo actually do its job.

<Parent> renders: 1handler called: 0×

memo'd child + plain function

<MemoChild>renders: 1

Wrapped in memo() but still re-renders — receives a new function object every time.

⚠ New fn reference every render → memo's prop check always fails

// New function object every render
const handleClick = () => {
  setCount(c => c + 1)
}

// memo() wraps the child — but the
// prop is a different object each
// time, so the check fails and the
// child still re-renders

memo'd child + useCallback

<MemoChild>renders: 1

Same memo() wrapper, but now receives a stable function reference from useCallback.

✓ Stable reference → memo's check passes → re-render skipped

// Same object reference across renders
const handleClick = useCallback(
  () => setCount(c => c + 1),
  [] // no deps → stable forever
)

// memo()'s check: prev === next ✓
// Child skips the re-render

useCallback alone is not enough. It only helps when the receiving component is wrapped in memo() - otherwise the child re-renders regardless of reference stability.

Suspense & lazy() - load what you need, when you need it

React.lazy() splits a component into a separate bundle loaded on demand. Suspense catches the loading state and shows a fallback - no manual loading flags needed. The demo below uses the same Suspense mechanism with a data resource.

Live demo

The dashboard component hasn't loaded yet. Clicking below creates a Suspense boundary — React shows the skeleton fallback until the resource resolves.

When HeavyDashboard calls resource.read(), it throws the pending Promise. React catches it, renders the fallback, and retries once the Promise resolves.

Code

// Split into a separate bundle
const Dashboard = lazy(
  () => import('./Dashboard')
)

// Suspense catches the loading state
function App() {
  return (
    <Suspense
      fallback={<DashboardSkeleton />}
    >
      <Dashboard />
    </Suspense>
  )
}

// React handles everything:
// 1. Renders fallback immediately
// 2. Loads the bundle in background
// 3. Swaps in Dashboard on resolve
// Nest for granular fallbacks
<Suspense fallback={<PageShell />}>
  <Layout>
    <Suspense fallback={<Spinner />}>
      <HeavyWidget />     {/* isolated */}
    </Suspense>
    <Sidebar />           {/* loads now */}
  </Layout>
</Suspense>

memo vs useMemo vs useCallback

All three are optimization tools, but they optimize different things. Understanding what each one memoizes is the key to using them correctly.

Tool
Memoizes
Primary purpose
Common use case
React.memo
Component
Skip unnecessary child re-renders
Large lists, expensive UI trees
useMemo
Computed value
Avoid expensive recalculations
Heavy filtering, sorting, math
useCallback
Function reference
Keep function identity stable
Passing callbacks to memoized children

React.memo

Wraps a component and skips re-rendering if its props are shallowly equal.

const Child = memo(function Child() {
  return <div />
})

useMemo

Caches the result of an expensive computation until dependencies change.

const result = useMemo(
  () => expensiveWork(data),
  [data]
)

useCallback

Returns the same function reference across renders until dependencies change.

const handleClick = useCallback(
  () => doSomething(),
  []
)

Important: these are performance optimization tools, not default wrappers for every component. React is already fast by default. Use them when profiling shows actual bottlenecks.