Most React performance work is unnecessary. The framework is fast enough for most use cases without any optimisation. The problems start when a component tree becomes large, when renders happen too frequently, or when expensive computations run on every render.
Here's my process for finding and fixing React performance issues in production.
Step 1: Confirm the problem exists
Before optimising anything, confirm that there's a real performance problem. "This component re-renders a lot" isn't a problem. "The user sees a 200ms delay when typing in the search field" is a problem.
I use two tools to confirm performance issues:
- React DevTools Profiler — records a session of renders and shows you which components rendered, how long they took, and why they rendered.
- Chrome DevTools Performance tab — records a broader trace including JavaScript execution, layout, paint, and compositing. Useful when the bottleneck isn't in React itself.
The React Profiler is the starting point. Open DevTools, go to the Profiler tab, click Record, interact with the application, stop recording. The flame graph shows every component that rendered during the session.
Look for:
- Components that render when they shouldn't (nothing relevant to them changed)
- Components that take more than 16ms to render (they cause a dropped frame)
- Components that render many times in rapid succession
Step 2: Identify the cause
React components re-render for three reasons:
- Their state changed
- Their parent re-rendered
- A context they consume changed
The React DevTools Profiler tells you which of these caused each render. Click on a component in the flame graph and it shows "Why did this render?" The answer is usually one of: "State update", "Props changed", or "The parent component rendered."
The most common performance issue I see is cascading re-renders from parent state changes. A parent component's state changes, all children re-render, and most of them produce the same output.
function Dashboard() {
const [searchQuery, setSearchQuery] = useState('')
return (
<div>
<SearchBar value={searchQuery} onChange={setSearchQuery} />
{/* These all re-render on every keystroke */}
<MetricsGrid />
<RecentActivity />
<UserList />
</div>
)
}
Every character typed in the search bar causes MetricsGrid, RecentActivity, and UserList to re-render, even though they don't depend on the search query.
Step 3: Apply the right fix
The fixes, in order of preference:
Move state down
The simplest fix: move the state closer to where it's used.
function Dashboard() {
return (
<div>
<SearchSection /> {/* state lives here now */}
<MetricsGrid />
<RecentActivity />
<UserList />
</div>
)
}
function SearchSection() {
const [query, setQuery] = useState('')
return (
<div>
<SearchBar value={query} onChange={setQuery} />
<SearchResults query={query} />
</div>
)
}
Now keystrokes only re-render SearchSection and its children. The other dashboard components are unaffected. No memo, no useMemo, no useCallback needed.
Memo for expensive children
If you can't move state down because the parent genuinely needs it, wrap expensive children in React.memo:
const MetricsGrid = memo(function MetricsGrid() {
// expensive render logic
return <div>{/* lots of metrics */}</div>
})
React.memo compares props shallowly. If the props haven't changed, the component doesn't re-render. This is a targeted fix: apply it to the specific components that are expensive, not to every component in the tree.
useMemo for expensive computations
If a component derives expensive data on every render, wrap the derivation in useMemo:
function AnalyticsChart({ data }: { data: DataPoint[] }) {
const processedData = useMemo(() => {
return data
.filter((d) => d.value > 0)
.sort((a, b) => a.timestamp - b.timestamp)
.map((d) => ({ x: d.timestamp, y: d.value }))
}, [data])
return <Chart data={processedData} />
}
The computation only re-runs when data changes. On re-renders where data is the same reference, the cached result is returned immediately.
Do not wrap everything in useMemo. The memoisation itself has a cost (comparing dependencies, storing the cached value). Only use it when the computation is measurably expensive, which usually means processing hundreds or thousands of items.
Virtualisation for long lists
If you're rendering a list with more than 100 items, virtualise it. Virtualisation means only rendering the items that are currently visible in the viewport, plus a small buffer above and below.
import { FixedSizeList } from 'react-window'
function UserList({ users }: { users: User[] }) {
return (
<FixedSizeList height={600} width="100%" itemSize={50} itemCount={users.length}>
{({ index, style }) => (
<div style={style}>
<UserRow user={users[index]} />
</div>
)}
</FixedSizeList>
)
}
react-window is the standard library for this. For variable-height items, use VariableSizeList. For grids, use FixedSizeGrid. The performance improvement on long lists is dramatic: rendering goes from O(n) to O(visible items), which is typically 10-20 regardless of list length.
What I don't bother optimising
- Components that render in under 1ms, even if they render frequently
- Re-renders that don't cause visible jank or input lag
- Components that only render on user interaction (click, navigation), not on continuous events (typing, scrolling)
The goal isn't zero unnecessary re-renders. The goal is a responsive user interface. If the interface feels fast, the render count is irrelevant.
The hierarchy of fixes (move state down → memo → useMemo → virtualise) is the most practical performance guide I've read. Every other article jumps straight to useMemo and useCallback without mentioning that restructuring the component tree is usually the better fix.
The point about not optimising components under 1ms is refreshing. I've seen teams spend days adding memo to every component without measuring whether there was a problem in the first place.
The Dashboard example with the search bar causing cascading re-renders is a bug I have shipped multiple times. Moving the search state into its own component is such an obvious fix in hindsight.