engineering 8 min read · November 15, 2025

Building Performant React Apps

Practical techniques for optimizing React applications that scale to production.

Every millisecond counts in production applications. React’s declarative model makes writing components easy, but without attention to rendering behavior, apps become sluggish. Here’s how to keep them fast.

Understanding React’s Rendering Model

React’s virtual DOM diffing is efficient, but unnecessary re-renders can bottleneck your application. The goal isn’t to render less — it’s to render only what changed.

When Components Re-render

A component re-renders when:

  1. Its state changes
  2. Its props change
  3. Its parent re-renders

The key insight: a parent’s re-render doesn’t mean children need to re-render. It means they’re scheduled to.

// This causes unnecessary re-renders of Button
function Parent() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState('');

  return (
    <div>
      <input value={name} onChange={(e) => setName(e.target.value)} />
      <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
      {/* Title re-renders even though its props didn't change */}
      <Title text="Hello" />
    </div>
  );
}

Memoization Strategies

React.memo for Component-Level Memoization

// Without memo: re-renders on every Parent render
function Title({ text }: { text: string }) {
  return <h1>{text}</h1>;
}

// With memo: only re-renders when text actually changes
const Title = React.memo(function Title({ text }: { text: string }) {
  return <h1>{text}</h1>;
});

useMemo and useCallback for Value Stability

function ProductList({ categoryId }: { categoryId: string }) {
  // memoize expensive computation
  const filteredProducts = useMemo(() => {
    return products.filter(p => p.categoryId === categoryId);
  }, [categoryId]);

  // memoize callback to pass to child
  const handleClick = useCallback((productId: string) => {
    navigate(`/products/${productId}`);
  }, []); // empty deps: function never changes

  return (
    <ul>
      {filteredProducts.map(p => (
        <ProductCard key={p.id} product={p} onClick={handleClick} />
      ))}
    </ul>
  );
}

Important: Don’t memoize everything. Memoization has a cost — comparison overhead. Profile first, optimize second.

Code Splitting

Route-Level Splitting

import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';

const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
const Analytics = lazy(() => import('./pages/Analytics'));

function App() {
  return (
    <Suspense fallback={<LoadingSpinner />}>
      <Routes>
        <Route path="/" element={<Dashboard />} />
        <Route path="/settings" element={<Settings />} />
        <Route path="/analytics" element={<Analytics />} />
      </Routes>
    </Suspense>
  );
}

Component-Level Splitting

// Only load the heavy chart when user scrolls it into view
const RevenueChart = lazy(() => import('./charts/RevenueChart'));

function Dashboard() {
  const [showChart, setShowChart] = useState(false);

  return (
    <div>
      <SummaryCards />
      {showChart && (
        <Suspense fallback={<ChartSkeleton />}>
          <RevenueChart />
        </Suspense>
      )}
      <button onClick={() => setShowChart(true)}>Show Revenue Chart</button>
    </div>
  );
}

Vite Dynamic Imports

// Chunk non-critical features
const handleExport = async () => {
  const { exportToPDF } = await import('./utils/exporter');
  exportToPDF(data);
};

Measuring Performance

React DevTools Profiler

The Profiler identifies which components render too often and why. Look for:

  • Components that render frequently without visible output change
  • “Highlight updates” during interaction to see what re-renders

Web Vitals in React

import { useEffect } from 'react';
import { onLCP, onINP, onCLS } from 'web-vitals';

function App() {
  useEffect(() => {
    function sendToAnalytics({ name, value, id }: Metric) {
      // Send to your analytics service
      fetch('/api/vitals', {
        method: 'POST',
        body: JSON.stringify({ name, value, id }),
      });
    }

    onLCP(sendToAnalytics);
    onINP(sendToAnalytics);
    onCLS(sendToAnalytics);
  }, []);

  return <Router />;
}

Performance Budgets in CI

// vitals.config.js
export default {
  lcp: { warn: 2500, fail: 4000 },
  inp: { warn: 200, fail: 500 },
  cls: { warn: 0.1, fail: 0.25 },
};

State Architecture for Performance

Colocation Principle

Keep state as local as possible. Move state up the tree only when shared is needed.

// Bad: state too high, causes unnecessary re-renders
function App() {
  const [searchQuery, setSearchQuery] = useState('');
  return (
    <div>
      <Header />
      <Search value={searchQuery} onChange={setSearchQuery} />
      <Content searchQuery={searchQuery} /> {/* re-renders on every keystroke */}
    </div>
  );
}

// Good: state collocated with where it's used
function SearchPage() {
  return (
    <div>
      <Header />
      <SearchWithState />
      <Content /> {/* only renders when its props change */}
    </div>
  );
}

Virtualization for Long Lists

import { useVirtualizer } from '@tanstack/react-virtual';

function VirtualList({ items }: { items: Item[] }) {
  const parentRef = useRef<HTMLDivElement>(null);
  const rowVirtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 72,
  });

  return (
    <div ref={parentRef} style={{ height: '100%', overflow: 'auto' }}>
      <div style={{ height: rowVirtualizer.getTotalSize() }}>
        {rowVirtualizer.getVirtualItems().map((virtualRow) => (
          <div
            key={items[virtualRow.index].id}
            style={{
              position: 'absolute',
              top: virtualRow.start,
              height: virtualRow.size,
            }}
          >
            <ItemRow item={items[virtualRow.index]} />
          </div>
        ))}
      </div>
    </div>
  );
}

Key Takeaways

  1. Profile before optimizing — React DevTools Profiler shows where time goes
  2. Memoize judiciously — React.memo, useMemo, useCallback have overhead
  3. Code split aggressively — lazy load routes and heavy components
  4. Colocate state — move state down, only lift when needed
  5. Virtualize long lists — render visible items only

Performance is a feature. Treat it as a first-class concern in code review, and set budgets in CI to catch regressions before production.

END OF ARCHIVE →