React9 min read
React Performance Optimization Checklist (2026 Edition)
A practical React performance optimization checklist: profile first, cut re-renders, memoize wisely, virtualize long lists, code split and shrink your bundle.
React performance optimization comes down to three questions: is the page loading too much JavaScript, is React rendering too much or too often, and is the DOM too large? Profile first with React DevTools and Chrome DevTools, then fix the biggest cause: code split heavy routes, stop unnecessary re-renders by fixing state placement before reaching for memoization, and virtualize long lists. This checklist walks through each step in the order I use on real projects.
Key takeaways
- Measure before optimizing. The React DevTools Profiler and the Chrome Performance panel tell you where the time actually goes.
- Most re-render problems are state placement problems. Move state down or lift content up before adding
memo. - With the React Compiler (stable since its 1.0 release in late 2025), manual
useMemo,useCallbackandmemobecome mostly unnecessary. - Virtualize lists with hundreds or thousands of rows.
- Ship less JavaScript: route-level code splitting, lazy-loaded heavy components and a regular look at your bundle.
- Use
useTransitionanduseDeferredValueto keep typing and clicking responsive during expensive updates.
Step 1: Profile before you optimize
Guessing is how teams end up with useCallback on every function and a still-slow page.
React DevTools Profiler
- Open React DevTools, go to the Profiler tab, and press record.
- Do the slow interaction (typing in a search box, opening a modal, switching tabs).
- Stop recording and read the flame graph. Grey components did not render. Coloured ones did, and the wider and more yellow the bar, the longer it took.
- Turn on "Record why each component rendered while profiling" in the Profiler settings. It tells you whether props, state, hooks or the parent caused a render.
Also try "Highlight updates when components render" in the DevTools settings. Seeing the whole page flash when you type one character is a fast way to find a state placement problem.
The <Profiler> component
For measuring in code (for example, to log slow renders in a staging build), React ships a Profiler component:
import { Profiler, type ProfilerOnRenderCallback, type ReactNode } from "react";
const onRender: ProfilerOnRenderCallback = (id, phase, actualDuration) => {
if (actualDuration > 16) {
console.warn(`[${id}] ${phase} render took ${actualDuration.toFixed(1)}ms`);
}
};
export function ProfiledDashboard({ children }: { children: ReactNode }) {
return (
<Profiler id="Dashboard" onRender={onRender}>
{children}
</Profiler>
);
}Profiling adds overhead and is disabled in standard production builds, so treat these numbers as relative, not absolute.
Chrome DevTools Performance panel
React DevTools only sees React. Long tasks from third-party scripts, layout thrashing, large images and slow network requests show up in the Chrome Performance panel. If your problem is a poor INP or LCP score, start there. I cover those metrics in Core Web Vitals for React.
Step 2: Prevent unnecessary re-renders in React
A component re-renders when its state changes, its parent re-renders, or a context it reads changes. Re-renders are normal and usually cheap. They become a problem when a large tree re-renders on every keystroke or every scroll event.
Move state down
The most common issue: state that only one small component needs lives at the top of the page.
import { useState } from "react";
// Before: typing re-renders ExpensiveChart on every keystroke
export function PageBefore() {
const [query, setQuery] = useState("");
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<ExpensiveChart />
</>
);
}
// After: only SearchInput re-renders while typing
function SearchInput() {
const [query, setQuery] = useState("");
return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}
export function PageAfter() {
return (
<>
<SearchInput />
<ExpensiveChart />
</>
);
}
function ExpensiveChart() {
// imagine heavy rendering here
return <div>Chart</div>;
}No memoization needed. This one change fixes a surprising number of "React is slow" complaints.
Lift content up with children
When a wrapper must own state (say, a scroll position or hover state), pass the expensive part as children. Elements created by the parent are not re-created when the wrapper's state changes.
import { useState, type ReactNode } from "react";
function HoverCard({ children }: { children: ReactNode }) {
const [hovered, setHovered] = useState(false);
return (
<div
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
className={hovered ? "card card--hover" : "card"}
>
{children}
</div>
);
}
// <HoverCard><ExpensiveChart /></HoverCard>
// Hovering does not re-render ExpensiveChart.Split contexts and select narrowly
A context with a frequently changing value re-renders every consumer. Split contexts by update frequency, or move fast-changing shared state into a store with selectors, like Zustand. I compare the options in React state management in 2026.
Step 3: Memoization with memo, useMemo and useCallback
Memoization is a tool for specific cases, not a default.
| API | What it caches | Use it when |
|---|---|---|
memo(Component) |
The rendered output for the same props | A component is expensive and often re-renders with identical props |
useMemo(fn, deps) |
A computed value | A calculation is genuinely slow, or a stable object reference is needed for a memoized child or effect |
useCallback(fn, deps) |
A function reference | You pass the function to a memoized child or use it in an effect dependency list |
The classic trap: wrapping a child in memo while passing it a new object or inline function every render. The props comparison fails every time, so you pay for the check and still re-render.
import { memo, useCallback, useMemo, useState } from "react";
type Row = { id: number; name: string; score: number };
const Table = memo(function Table({
rows,
onSelect,
}: {
rows: Row[];
onSelect: (id: number) => void;
}) {
return (
<ul>
{rows.map((r) => (
<li key={r.id} onClick={() => onSelect(r.id)}>
{r.name}: {r.score}
</li>
))}
</ul>
);
});
export function Leaderboard({ data }: { data: Row[] }) {
const [selected, setSelected] = useState<number | null>(null);
const [minScore, setMinScore] = useState(0);
const rows = useMemo(
() => data.filter((r) => r.score >= minScore).sort((a, b) => b.score - a.score),
[data, minScore],
);
const handleSelect = useCallback((id: number) => setSelected(id), []);
return (
<>
<input
type="number"
value={minScore}
onChange={(e) => setMinScore(Number(e.target.value))}
/>
<p>Selected: {selected ?? "none"}</p>
<Table rows={rows} onSelect={handleSelect} />
</>
);
}Selecting a row updates selected, but Table skips rendering because rows and handleSelect keep the same references.
Let the React Compiler do it
The React Compiler analyses your components at build time and inserts this memoization automatically. Next.js 16 has stable built-in support through the reactCompiler option, and on Vite 8, @vitejs/plugin-react v6 ships a reactCompilerPreset helper that runs the compiler through @rolldown/plugin-babel. With the compiler on, write plain components and only add manual memoization where profiling shows the compiler could not help. Details in React Compiler: do you still need useMemo and useCallback?.
Step 4: Keep interactions responsive with transitions
Sometimes the render is expensive and you cannot avoid it, such as filtering a large dataset as the user types. Mark the expensive update as non-urgent so the input stays responsive.
import { useDeferredValue, useMemo, useState } from "react";
export function ProductSearch({ products }: { products: { id: string; name: string }[] }) {
const [query, setQuery] = useState("");
const deferredQuery = useDeferredValue(query);
const isStale = query !== deferredQuery;
const results = useMemo(
() =>
products.filter((p) =>
p.name.toLowerCase().includes(deferredQuery.toLowerCase()),
),
[products, deferredQuery],
);
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<ul style={{ opacity: isStale ? 0.6 : 1 }}>
{results.map((p) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
</>
);
}useDeferredValue lets React render the input with the new value first, then render the list with the deferred value in the background. useTransition does the same for state updates you trigger yourself, such as switching tabs. Note that the expensive work still needs to be skipped when the deferred value has not changed. Here useMemo skips the filtering; if rendering the rows is the slow part, move the list into its own memo component that receives deferredQuery as a prop.
Step 5: Virtualize large lists
Rendering 5,000 rows creates 5,000 sets of DOM nodes, no matter how well you memoize. Virtualization renders only the rows in view.
npm install @tanstack/react-virtualimport { useRef } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
export function VirtualList({ items }: { items: string[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 40,
overscan: 5,
});
return (
<div ref={parentRef} style={{ height: 480, overflow: "auto" }}>
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
{virtualizer.getVirtualItems().map((row) => (
<div
key={row.key}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: row.size,
transform: `translateY(${row.start}px)`,
}}
>
{items[row.index]}
</div>
))}
</div>
</div>
);
}Gotchas: the scroll container needs a fixed height, browser find-in-page will not see off-screen rows, and rows with variable height need the measureElement ref from the virtualizer. For lists of 50 items, skip all of this.
Step 6: Code splitting and lazy loading
Load performance often matters more than render performance. Every kilobyte of JavaScript must be downloaded, parsed and executed before the page becomes interactive.
import { lazy, Suspense, useState } from "react";
const ReportEditor = lazy(() => import("./ReportEditor"));
export function Reports() {
const [editing, setEditing] = useState(false);
return (
<>
<button onClick={() => setEditing(true)}>Edit report</button>
{editing && (
<Suspense fallback={<p>Loading editor...</p>}>
<ReportEditor />
</Suspense>
)}
</>
);
}lazy needs a module with a default export. Good candidates: routes, rich text editors, chart libraries, maps, admin-only screens and anything behind a click. Frameworks such as Next.js and React Router framework mode split by route automatically, so focus your manual splitting on heavy components inside a route.
On a service booking platform I worked on, code splitting, lazy loading and memoization were the three techniques we relied on. The lesson I took from it: each one is worth applying where a recording shows a problem, not everywhere by default.
Step 7: Reduce React bundle size
- Look at the bundle. Build with source maps and run a tool like
source-map-explorer, or use your framework's analyzer, at least before each major release. - Check what a dependency costs before adding it. A date library, an icon pack imported as a whole, or a full lodash import can outweigh your own code.
- Import narrowly. Prefer
import debounce from "lodash-es/debounce"style imports or native APIs (Intl,structuredClone,Array.prototype.toSorted). - Move work to the server. With React Server Components, components that only render data never ship their code to the browser. See React Server Components explained.
- Remove dead dependencies. Old polyfills and abandoned experiments tend to stay in
package.jsonfor years.
Common React performance mistakes
- Optimizing without a recording that shows the problem.
- Wrapping everything in
memo,useMemoanduseCallback"just in case". - Using array index as
keyin lists that reorder, which causes extra DOM work and state bugs. - Defining components inside other components. The inner component is a new type every render, so React unmounts and remounts it.
- Storing derived values in state and syncing them with
useEffect, which adds an extra render on every change. - Testing only in development mode. Development builds are slower on purpose (Strict Mode double rendering, extra checks). Confirm problems in a production build.
The official React DevTools Profiler docs are a good reference for the onRender arguments.
FAQ
How do I find what is slow in a React app?
Record an interaction with the Profiler tab in React DevTools and look for components with long render times or many renders. Pair it with the Performance panel in Chrome DevTools to see long tasks, network waterfalls and layout work that React DevTools does not show.
Should I wrap every component in React.memo?
No. memo adds a props comparison on every render and only helps when a component re-renders often with the same props and is expensive to render. Measure first, and if you use the React Compiler, it handles most of this memoization automatically.
When should I virtualize a list in React?
Virtualize when a list renders hundreds or thousands of rows and scrolling or initial render feels slow. Libraries like TanStack Virtual render only the visible rows plus a small overscan, which keeps the DOM small.
Does code splitting improve React performance?
It improves load performance by shipping less JavaScript up front. Use React.lazy with Suspense, or your framework's route-based splitting, for routes and heavy components that are not needed on first paint.
Wrapping up
Profile, then fix the biggest problem first. In most apps that means moving state closer to where it is used, splitting heavy code out of the initial bundle and virtualizing the one giant list. Memoization comes last, and with the React Compiler, much of it is handled for you.
- #react
- #performance
- #optimization
- #web-vitals
Keep reading
React8 min read
React Compiler: Do You Still Need useMemo and useCallback?
React Compiler 1.0 memoizes components for you. Learn when useMemo and useCallback are still needed, how to enable it in Vite or Next.js 16, and what breaks.
Read articleReact7 min read
10 Custom React Hooks Worth Having (TypeScript Examples)
10 custom React hooks in TypeScript: useDebounce, useLocalStorage, useMediaQuery, useOnClickOutside, useInterval and more, built for React 19 and the compiler.
Read articleReact8 min read
React 19 Hooks: use, useActionState, useOptimistic Explained
React 19 hooks explained with TypeScript examples: use, useActionState, useOptimistic and useFormStatus, plus the gotchas that trip people up in real forms.
Read article