The Two Rules of React Performance
"Not doing stuff is faster than doing stuff."
These two rules sound obvious. But most React performance problems come from violating exactly one of them — and more often than not, it's the first one.
Rule 1 — Don't Do Unnecessary Work
The biggest performance win is usually not doing something at all.
Code that doesn't run is infinitely faster than code that does. The theoretical ideal: a React app that never has to re-render is the world's fastest React app. (You could argue that's just an HTML page — we're not taking those questions right now.) Every step closer to "never re-renders unnecessarily" is a step toward better performance.
Before reaching for useMemo, useCallback, or React.memo, ask: does this component need to re-render right now? Often the answer is no — because its state or props didn't actually change in a way it cares about.
The most common culprits:
- State lifted too high — a parent re-renders and takes 10 children with it, even though only one of them cares about the state change.
- New object/array references on every render —
const config = { … }inside a component creates a new object each render. Child components that receive it as a prop will always see a "change". - Context that changes too often — a context value changing causes every consumer to re-render, even if the specific value a consumer reads didn't change.
State Colocation
The first fix I reach for is moving state down. If state only affects a subtree, move it into that subtree. The rest of the tree won't know or care when it changes.
// ❌ state lifted too high — FilterBar changing causes EntireList to re-render
function Page() {
const [query, setQuery] = useState('');
return (
<>
<FilterBar query={query} onChange={setQuery} />
<EntireList /> {/* re-renders every time query changes */}
</>
);
}
// ✅ state colocated — FilterBar owns its own state
function Page() {
return (
<>
<FilterBar /> {/* self-contained */}
<EntireList /> {/* never re-renders due to filter changes */}
</>
);
}The Memoization Trap
Memoization — React.memo, useMemo, useCallback — is still doing something. It's caching. And as the famous quote goes:
"The two hardest problems in computer science are naming things, cache invalidation, and off-by-one errors."
If you slap React.memo on everything, you're now asking React to check on every render whether the memoized result is still valid. If the thing was cheap to render in the first place, you've made it slower — you're doing more work to avoid work that was never expensive.
Memoization pays off when the cost of the check is less than the cost of re-rendering. It backfires when you over-apply it, because now you have cache invalidation bugs: a component isn't updating because React thinks nothing changed, but it actually has. These are some of the hardest bugs to debug.
The rule: reach for structural fixes first, memoization last.
Rule 2 — If You Have to Do Work, Make It Feel Fast
Sometimes you can't avoid the work. A large list must render. A heavy calculation must run. In that case, the goal shifts to perceived performance — making the UI feel responsive even while work is happening.
Feeling fast is pretty much as good as actually being fast. A few examples:
- Preloading — fetching the next route before the user clicks. Not technically faster, but it feels instant.
- Optimistic UI — updating the UI before the server responds. The server hasn't gotten faster; the waiting has just been hidden.
- Prioritising urgent work — showing the input update immediately while the heavy list render catches up in the background.
Content Sites vs Apps
This is where context matters. The performance goal for a marketing page and a web app are different:
Content site: Time to first meaningful paint is critical. Users bounce if content is slow. Critical CSS, getting words on screen first, deferring ad tracking — all of that matters.
App: Users are in it for the long haul. Paying a slightly higher upfront cost so that every subsequent interaction is snappy is often the right trade-off. A Gmail-style loading bar is fine because once you're in, everything is fast.
React's newer primitives — useTransition, useDeferredValue — let you make this distinction explicitly in code: this update is urgent, that one can wait.
React's New Hammers
In the early days of React, you had one hammer: render everything, synchronously, as fast as possible. Now there are four or five:
| Tool | What it does |
|---|---|
useTransition | Marks a state update as non-urgent — UI stays responsive while React works |
useDeferredValue | Defers a value's re-render until the browser has idle time |
React.memo | Skips re-rendering a component if its props haven't changed |
useMemo | Memoizes an expensive computation across renders |
useCallback | Memoizes a function reference across renders |
Suspense | Progressively loads UI — show what you have, stream in the rest |
These aren't complicated to use once you understand which problem each one solves. The complexity is knowing they exist and knowing which nail they're for.
The Performance Roadmap
The way I think about React performance work has three layers, roughly in order of impact:
- App shape — state colocation, lifting state down, structural changes. The biggest wins, no extra APIs needed.
- Selective caching —
React.memo,useMemo,useCallback, and Suspense for progressive loading. Applied carefully, not everywhere. - Scheduling —
useTransition,useDeferredValue. For when work must happen, but can be deprioritised.
Each layer builds on the one before. If layer 1 is solid, layer 3 might never be needed.
Why Measure Before You Optimise
Every performance fix should start with a measurement. Without data:
- You don't know where the slowdown actually is
- You might "fix" something that was already fast
- You have no way to confirm the fix worked
React DevTools Profiler — records which components rendered and how long each one took. The flame graph shows you the render tree at a glance.
Why Did You Render — library that logs to console whenever a component re-renders unexpectedly. Useful for catching the "I thought memo would prevent this" bugs.
Browser Performance tab — shows the full call stack, including layout, paint, and compositing. Useful when the JS is fast but the browser is still slow.
Practice what you just read.