React is fast out of the box thanks to the Virtual DOM, but as an app grows to thousands of components, rendering everything on every state change can cause visible lag. Performance optimization is fundamentally about skipping unnecessary work.
1What is React Performance?
React re-renders a component whenever its state or props change, and by default, a parent re-rendering also re-renders every one of its children ā even ones whose own props never actually changed. In a small app that's imperceptible, but in a large tree it can produce visible jank.
Optimization in React isn't about making individual renders faster; it's about correctly identifying and skipping the renders that don't need to happen at all.
// React Performance: Squeezing every millisecondOptimization
Skip useless work.
2React.memo
React.memo is a higher-order component that wraps a component and memorizes its last rendered output. On the next parent re-render, if the props passed to the wrapped component are identical (by shallow comparison) to the previous render, React skips re-rendering it entirely and reuses the cached output.
This is most valuable for components that are expensive to render but receive the same props most of the time ā wrapping a cheap component in memo adds comparison overhead without meaningful benefit.
const MyComponent = React.memo((props) => {
return <div>{props.data}</div>;
});React.memo
The Render Guard.
3Code Splitting & Lazy Loading
Rather than forcing every user to download the entire application's JavaScript before anything renders, React.lazy() lets you split off a component (like a rarely-visited Settings page) into its own chunk, downloaded only when it's actually needed.
Because the lazy-loaded chunk takes a moment to arrive over the network, it must be wrapped in a <Suspense fallback={...}> boundary, which renders a fallback UI ā typically a loading spinner ā until the chunk finishes downloading.
const Profile = React.lazy(() => import('./Profile'));
<Suspense fallback={<Loading />}>
<Profile />
</Suspense>Lazy Loading
Load on demand.
4The React Profiler
Guessing at what to optimize is unreliable ā the React Developer Tools browser extension includes a Profiler tab specifically to remove the guesswork. Recording an interaction produces a flamegraph showing exactly which components rendered, how long each one took, and why it re-rendered.
This turns performance work from intuition into measurement: instead of memoizing components speculatively, you can target the specific components the Profiler actually flags as slow.
// Check the 'Profiler' tab in React DevTools
// Look for 'Yellow' bars (slow renders)Profiler
Stop guessing.
5Virtualization (Windowing)
Rendering all 10,000 DOM nodes for a massive list would visibly stutter or crash the browser tab. Virtualization (also called windowing) solves this by rendering only the roughly 20 items currently visible in the viewport, recycling and swapping their content as the user scrolls.
Libraries like react-window implement this pattern ā the scrollbar behaves as if all 10,000 items exist, but the actual DOM never holds more than a small, constant number of nodes at any time.
import { FixedSizeList } from 'react-window';
<FixedSizeList height={500} itemCount={10000} itemSize={50}>Virtualization
DOM Conservation.
6Automatic Batching
React 18 introduced automatic batching: even if a setTimeout callback or a resolved fetch promise updates five separate state variables, React groups all of them together and performs exactly one re-render, not five.
Prior to React 18, this batching only happened reliably inside React event handlers ā updates inside promises or timeouts triggered a separate render for each call. Automatic batching extends that same efficiency to every context, with no extra code required.
setCount(c => c + 1);
setFlag(f => !f);
// Both happen in ONE render cycle! ā
Batching
Grouped updates.
7Simulating a 10k List
Rendering 10,000 native DOM nodes directly is slow enough to visibly stutter the browser, and scrolling through them compounds the cost every frame. A virtualized version of the same list keeps roughly 20 DOM nodes alive at any given time, simply swapping their displayed text as the scroll position changes.
From the user's perspective the scrollbar behaves identically to a full 10,000-node list, but the actual DOM stays tiny, and the frame rate stays smooth regardless of the underlying data size.
/* Optimization Lab: 10k List Scrolling Rendered */Scroll Simulation
8Anonymous Functions in Props
Writing an inline arrow function directly in a prop, like onClick={() => doThing()}, creates a brand-new function reference in memory on every single render of the parent. If that function is passed to a React.memo-wrapped child, the memoization breaks silently ā the child sees a 'new' prop every time and re-renders anyway.
The fix is wrapping the handler in useCallback so it keeps the same reference across renders (unless its own dependencies change), letting React.memo correctly detect that the prop truly didn't change.
const [isPending, startTransition] = useTransition();
startTransition(() => { setFilter(val); });Prop References
Protect your memos.
9The Transition API
React 18's useTransition hook lets you mark certain state updates as 'non-urgent.' Updating a search box's text as the user types is urgent and should feel instant; re-filtering a 10,000-item list based on that text is expensive but can tolerate a slight delay.
Wrapping the expensive update in startTransition(() => setFilteredList(...)) tells React it's allowed to interrupt that work if something more urgent (like the next keystroke) comes in, keeping the UI responsive even while heavy filtering happens in the background.
/* Next: Capstone Dashboard */Transitions
Concurrent features.
10Bundle Analysis: Measuring Before Splitting
Code splitting only helps if you know what's actually bloating the bundle in the first place. A bundle visualizer, such as rollup-plugin-visualizer for a Vite project, renders an interactive treemap of the final production JavaScript, immediately showing which dependencies consume the most space before any splitting decisions are made.
plugins: [react(), visualizer({ open: true })]Bundle Analysis
Measure before you split
11Reading a Treemap for Optimization Targets
In the treemap, each rectangle is a module sized by its share of the final bundle. A large rectangle for a rarely-used library still bundled into the main chunk is a strong code-splitting candidate; a large rectangle from a whole-library default import (instead of tree-shakeable named imports) signals an import pattern worth fixing.
const AdvancedChart = React.lazy(() => import('./AdvancedChart'));Every rectangle is a real optimization decision
12Step-by-Step Breakdown
What is React Performance?. React is incredibly fast out of the box thanks to the Virtual DOM. However, as applications grow to thousands of components, rendering everything on every state change can cause 'jank' or lag. Optimization is about skipping unnecessary work.
React.memo. React.memo is a Higher-Order Component. If you wrap a component in it, React will memorize the rendered output. On the next render, if the props passed to the component are identical, React skips rendering it entirely.
Code Splitting & Lazy Loading. Instead of forcing the user to download a massive 5MB JavaScript file before the app starts, you can 'Code Split'. Using React.lazy() and <Suspense>, you load chunks of code (like a Settings page) only when the user clicks on it.
Which React feature allows you to specify a fallback UI (like a spinner) while a dynamically imported component is being downloaded?
- āDelay
- āSuspense
The React Profiler. How do you know what to optimize? Guessing is bad. The React Developer Tools extension includes a 'Profiler' tab. You record your interaction, and it gives you a flamegraph showing exactly which components took the longest to render and why.
Virtualization (Windowing). If you have a list of 10,000 items, rendering all 10,000 DOM nodes will crash the browser. 'Virtualization' renders ONLY the 20 items currently visible on the screen, replacing them as you scroll. Use libraries like react-window.
Automatic Batching. React 18 introduced Automatic Batching. Even if you update 5 different state variables inside a setTimeout or fetch promise, React groups them together and performs exactly ONE re-render. You get speed for free.
Simulating a 10k List. Watch the browser pane. Rendering 10,000 native DOM nodes takes seconds and stutters. Virtualization keeps exactly 20 nodes alive in memory and swaps their text as you scroll. The scrollbar thinks it's huge, but the DOM is tiny.
If a component frequently re-renders but its output is identical because its props haven't changed, which HOC can skip that unnecessary work?
- āReact.lazy
- āReact.memo
Anonymous Functions in Props. Be careful with inline anonymous functions onClick={() => doThing()}. Every time the parent renders, a brand NEW function reference is created in memory. If you pass this to a React.memo child, the memoization breaks because the prop 'changed'.
The Transition API. In React 18, useTransition lets you mark some state updates as 'non-urgent'. For example, if a user types in a search box, updating the text input is urgent. Filtering a 10,000 item list is non-urgent. Transitions keep the UI responsive while heavy work happens.
True or False: In React 18, state updates inside a setTimeout or fetch.then() are automatically batched into a single render.
- āTrue
- āFalse
useMemo for Calculations. We talked about React.memo for components, but what about heavy math? useMemo caches the RESULT of a calculation. It will only recalculate the math if the dependencies change, saving CPU cycles on every render.
useCallback for Functions. Similarly, useCallback caches a FUNCTION DEFINITION. This prevents the 'Anonymous Function' prop problem we saw earlier. It ensures child components receive the exact same function reference unless dependencies change.
Bundle Analysis: Measuring Before Splitting. Code splitting only helps if you actually know what's bloating your bundle in the first place. A bundle visualizer (like rollup-plugin-visualizer for Vite) renders an interactive treemap of your final JavaScript ā instantly showing which dependencies are eating the most space, before you decide what to lazy-load.
Why check a bundle visualizer's treemap BEFORE deciding what to code-split or lazy-load?
- āIt reveals what's actually taking up space, instead of guessing which imports are heavy
- āVite refuses to build a production bundle without it
Reading a Treemap for Optimization Targets. In the treemap, each rectangle is a module, sized by its contribution to the final bundle. A large rectangle for a rarely-used charting library on your main bundle is a code-splitting target; a large rectangle for a utility library imported via its default export (not tree-shaken) is a target for switching to named imports instead.
Mastery Achieved. Optimization mastery achieved! You've learned to build lightning-fast applications. You know how to use memoization, virtualization, code splitting, concurrent features, and how to measure a real bundle with a visualizer before deciding what to optimize.
Level Up š
Advanced cheat sheets, SEO tricks, and interview prep for this topic.
Browser Support
Fully supported.
Fully supported.
Fully supported.
Fully supported.
Accessibility (A11y)
1Virtualized Lists Need ARIA Roles Real `<ul>`/`<li>` Would Provide for Free
Because a windowing library only renders visible DOM nodes, it doesn't automatically expose the full list size or position to screen readers ā add `role="listbox"`/`role="option"` (or `aria-setsize`/`aria-posinset`) so assistive technology understands the true scope of the list, not just the currently-mounted subset.
2useTransition-Deferred Updates Should Still Announce Completion
If a heavy filter operation wrapped in `startTransition` takes a noticeable moment to resolve, surface `isPending` as a loading indicator (and ideally an `aria-live` announcement) so users ā especially those relying on a screen reader ā know the results are still catching up to their input.
SEO Implications
- 1
Lazy-Loaded Route Components Can Delay Content From Appearing to Crawlers
If a crawler doesn't wait for a `React.lazy()` chunk to resolve (or the app has no SSR/prerendering), the content inside that lazy boundary may never appear in what gets indexed ā reserve code-splitting for genuinely secondary routes, not primary content.
- 2
Virtualized Lists Can Hide Content From Text-Based Indexing
A crawler evaluating a virtualized list's DOM only sees the small number of currently-rendered items, never the full underlying dataset ā if that list's full contents matter for SEO (like a product catalog), render a separate, fully-listed version for crawlers or use server-side pagination instead of pure client-side virtualization.
Best Practices
Profile Before Optimizing, Never After Guessing
Reach for the React DevTools Profiler to identify the actual slow components before wrapping things in `memo`/`useMemo`/`useCallback` ā premature memoization adds comparison overhead and code complexity without a measured benefit.
Reserve Virtualization for Lists That Are Actually Large
The overhead of a windowing library only pays off once a list is large enough (typically hundreds of items or more) that rendering all of it would genuinely cause jank ā applying it to a 20-item list adds complexity for no measurable gain.
Frequent Bugs
Wrapping a component in `React.memo` doesn't stop it from re-rendering.
Check whether any prop passed to it is a new object, array, or function reference created inline on every parent render (like an inline arrow function or `{...spread}` object) ā `React.memo`'s default shallow comparison sees these as 'changed' every time, even if their contents are identical. Memoize those values with `useMemo`/`useCallback` in the parent.
A `useTransition`-wrapped update never seems to actually defer ā the UI still freezes during the heavy computation.
The expensive state update itself must be the one wrapped inside `startTransition(() => ...)`, not a separate, unrelated synchronous computation happening outside of it. Also confirm the urgent update (like the input value itself) is set outside the transition so it stays instant.
Real-World Examples
Responsive Search-and-Filter Over a Large List
A dashboard's search box updates instantly as the user types, while the expensive filtering of a 10,000-row dataset is deferred via `useTransition` so keystrokes never feel blocked by the heavier computation.
const [isPending, startTransition] = useTransition();
const handleChange = (e) => {
setQuery(e.target.value); // urgent
startTransition(() => setResults(bigList.filter(matchesQuery))); // deferred
};