Understand why React re-renders, where bottlenecks come from, and which tools to reach for.
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×
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.
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.
without memo
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 changedwith React.memo
prop: 0
✓ Skips re-render when prop unchanged
const Child = memo(function Child({
value,
}) {
return <div>{value}</div>
})
// Skips if props are shallowly equalReact.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 re-runs the computation only when its dependencies change. Trigger an unrelated re-render — the right panel skips the work entirely.
without useMemo
fibonacci(28) = 317811
// Recomputes on every render const result = fibonacci(n) // Unrelated state changes // still run this expensive work
with useMemo
fibonacci(28) = 317811
// 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.
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.
memo'd child + plain function
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-rendersmemo'd child + useCallback
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.
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>All three are optimization tools, but they optimize different things. Understanding what each one memoizes is the key to using them correctly.
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.