On this page
Performance and Optimization: React Compiler, Profiling, and Code Splitting
Understanding React Rendering
Before optimizing, you need to understand when and why React re-renders:
- State change: calling a
setStatefunction triggers a re-render of that component and all its descendants. - Props change: when a parent re-renders, all child components re-render too — even if their props did not change (unless memoized).
- Context change: all consumers of a context re-render when the context value changes.
The goal of optimization is to minimize unnecessary re-renders — renders where the output would be identical to the previous render.
The React Compiler
React 19's React Compiler automatically analyzes your component code and inserts useMemo, useCallback, and React.memo equivalents where they will be beneficial. This means:
- You no longer need to manually wrap every callback with
useCallback - You no longer need to wrap every expensive calculation with
useMemo - The compiler is more aggressive and accurate than manual optimization
// Before React Compiler — you wrote this:
function ProductFilter({ products, category, onSelect }) {
const filtered = useMemo(
() => products.filter((p) => p.category === category),
[products, category]
);
const handleSelect = useCallback(
(id: number) => onSelect(id),
[onSelect]
);
return <ProductList items={filtered} onSelect={handleSelect} />;
}
// With React Compiler — you write this:
function ProductFilter({ products, category, onSelect }) {
const filtered = products.filter((p) => p.category === category);
const handleSelect = (id: number) => onSelect(id);
return <ProductList items={filtered} onSelect={handleSelect} />;
}
// The compiler generates the memoized version automaticallyEnabling the Compiler
npm install babel-plugin-react-compilerIn vite.config.ts:
react({
babel: {
plugins: [['babel-plugin-react-compiler', {}]],
},
})Code Splitting with React.lazy
Code splitting divides your JavaScript bundle into smaller chunks that are loaded on demand. This reduces the initial page load time significantly:
import { lazy, Suspense } from 'react';
// Without code splitting — everything in one bundle
// import { AdminPanel } from './AdminPanel'; // 150kB
// With code splitting — loaded only when user navigates to /admin
const AdminPanel = lazy(() => import('./AdminPanel'));
function ProtectedRoute() {
const { isAdmin } = useAuth();
if (!isAdmin) return <p>Access denied.</p>;
return (
<Suspense fallback={<p>Loading admin panel...</p>}>
<AdminPanel />
</Suspense>
);
}Transitions with useTransition
useTransition lets you mark certain state updates as non-urgent, keeping the UI responsive while heavy work happens in the background:
import { useTransition, useState } from 'react';
interface SearchResult {
id: number;
title: string;
}
function SearchPage() {
const [query, setQuery] = useState('');
const [results, setResults] = useState<SearchResult[]>([]);
const [isPending, startTransition] = useTransition();
async function handleSearch(e: React.ChangeEvent<HTMLInputElement>) {
const value = e.target.value;
setQuery(value); // urgent — input must update immediately
startTransition(async () => {
// non-urgent — can be interrupted by the next keystroke
const res = await fetch(`/api/search?q=${encodeURIComponent(value)}`);
const data = await res.json() as SearchResult[];
setResults(data);
});
}
return (
<div>
<input
type="search"
value={query}
onChange={handleSearch}
aria-label="Search"
/>
{isPending && (
<p aria-live="polite" aria-busy="true">Searching...</p>
)}
<ul style={{ opacity: isPending ? 0.6 : 1 }}>
{results.map((r) => (
<li key={r.id}>{r.title}</li>
))}
</ul>
</div>
);
}Virtualization for Long Lists
When rendering thousands of items, use a virtualization library to only render what is visible in the viewport:
import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef } from 'react';
interface LogEntry {
id: string;
timestamp: number;
message: string;
level: 'info' | 'warn' | 'error';
}
function VirtualLogViewer({ logs }: { logs: LogEntry[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: logs.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 40, // estimated row height in px
overscan: 10, // render 10 extra items above/below viewport
});
return (
<div
ref={parentRef}
style={{ height: '500px', overflowY: 'auto' }}
role="log"
aria-label="Application log"
>
<div style={{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }}>
{virtualizer.getVirtualItems().map((virtualItem) => {
const log = logs[virtualItem.index];
return (
<div
key={log.id}
style={{
position: 'absolute',
top: `${virtualItem.start}px`,
width: '100%',
height: `${virtualItem.size}px`,
}}
>
<span className={`log-level log-level--${log.level}`}>{log.level}</span>
<code>{log.message}</code>
</div>
);
})}
</div>
</div>
);
}Bundle Analysis
Identify what is taking up space in your JavaScript bundle:
npm install --save-dev rollup-plugin-visualizer
# Build with analyzer
npm run build
# Opens stats.html showing bundle compositionCommon large dependencies to watch for:
| Library | Alternative |
|---|---|
moment.js (330kB) |
date-fns or dayjs |
lodash (70kB) |
Native array methods |
| Full icon set | Tree-shakeable icons (lucide-react) |
recharts |
Import only needed charts |
React DevTools Profiler
The React DevTools Profiler records rendering performance:
- Open Chrome DevTools → React DevTools → Profiler tab
- Click Record
- Interact with the slow part of the UI
- Stop recording
- Examine the flame graph
Look for:
- Gray bars: unchanged components that were skipped (good)
- Yellow/orange bars: components that re-rendered (investigate if slow)
- Red bars: very slow renders (optimize these)
The "Why did this render?" feature shows exactly what caused each component to re-render.
Image Optimization
Images are often the largest assets on a page. Optimize them with:
function OptimizedHeroImage() {
return (
<picture>
<source
type="image/avif"
srcSet="/hero.avif 1x, /[email protected] 2x"
/>
<source
type="image/webp"
srcSet="/hero.webp 1x, /[email protected] 2x"
/>
<img
src="/hero.jpg"
alt="Platform overview showing dashboard and analytics"
width={1280}
height={720}
loading="eager" // LCP image should NOT lazy-load
fetchPriority="high"
decoding="sync"
/>
</picture>
);
}
// Non-LCP images should lazy-load
function ProductImage({ src, alt }: { src: string; alt: string }) {
return (
<img
src={src}
alt={alt}
width={300}
height={300}
loading="lazy" // defer off-screen images
decoding="async"
/>
);
}In the next lesson, you will learn how to write comprehensive tests for React components and hooks using Vitest and Testing Library.
import { lazy, Suspense, startTransition, useState } from 'react';
import { createBrowserRouter, RouterProvider } from 'react-router';
// Each import() creates a separate JS chunk
const HomePage = lazy(() => import('./pages/HomePage'));
const DashboardPage = lazy(() =>
import('./pages/DashboardPage').then((m) => ({
default: m.DashboardPage,
}))
);
const AnalyticsPage = lazy(() => import('./pages/AnalyticsPage'));
const SettingsPage = lazy(() => import('./pages/SettingsPage'));
function PageSkeleton() {
return (
<div className="page-skeleton" aria-busy="true" aria-label="Loading page">
<div className="skeleton skeleton--header" />
<div className="skeleton skeleton--body" />
</div>
);
}
const router = createBrowserRouter([
{
path: '/',
element: <HomePage />,
},
{
path: '/dashboard',
element: <DashboardPage />,
},
{
path: '/analytics',
element: <AnalyticsPage />,
},
{
path: '/settings',
element: <SettingsPage />,
},
]);
export function App() {
return (
<Suspense fallback={<PageSkeleton />}>
<RouterProvider router={router} />
</Suspense>
);
}
// useTransition example — non-urgent state update
function SearchWithTransition() {
const [query, setQuery] = useState('');
const [results, setResults] = useState<string[]>([]);
const [isPending, startTransitionFn] = useState(false);
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const value = e.target.value;
setQuery(value);
// Mark search results update as non-urgent
startTransition(() => {
const filtered = heavyFilter(value);
setResults(filtered);
});
}
return (
<div>
<input
type="search"
value={query}
onChange={handleChange}
aria-label="Search"
/>
{isPending && <span aria-live="polite">Searching...</span>}
<ul>
{results.map((r) => <li key={r}>{r}</li>)}
</ul>
</div>
);
}
function heavyFilter(query: string): string[] {
const items = Array.from({ length: 10000 }, (_, i) => `Item ${i}`);
return items.filter((item) => item.toLowerCase().includes(query.toLowerCase()));
}
Sign in to track your progress