On this page

Performance and Optimization: React Compiler, Profiling, and Code Splitting

12 min read TextCh. 5 — Production

Understanding React Rendering

Before optimizing, you need to understand when and why React re-renders:

  1. State change: calling a setState function triggers a re-render of that component and all its descendants.
  2. Props change: when a parent re-renders, all child components re-render too — even if their props did not change (unless memoized).
  3. 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 automatically

Enabling the Compiler

npm install babel-plugin-react-compiler

In 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 composition

Common 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:

  1. Open Chrome DevTools → React DevTools → Profiler tab
  2. Click Record
  3. Interact with the slow part of the UI
  4. Stop recording
  5. 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.

Profile before optimizing
Open React DevTools Profiler, click Record, interact with the slow part of your app, stop recording, and look for components with long render times (shown in orange/red). Only optimize what the profiler identifies as a bottleneck — premature optimization adds complexity without measurable benefit.
startTransition marks updates as non-urgent
Use `startTransition` from React for state updates that can be interrupted — like filtering a large list or navigating between tabs. React will process these updates during idle time, keeping the UI responsive. The `isPending` flag lets you show a loading indicator without blocking interaction.
React Compiler requires React 19+
The React Compiler only works with React 19 and above. If you are on React 18, you still need `useMemo`, `useCallback`, and `React.memo` for manual optimization. Check that your eslint configuration includes `eslint-plugin-react-compiler` to catch violations in the compiler's expected code patterns.
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()));
}