Skip to content

React Performance: Patterns That Move the Needle

Practical React performance techniques — memoization, virtualization, code splitting and profiling — with real before/after benchmarks and their tradeoffs.

3 min read
The React logo at the centre of arrows fanning out to six patterns: profile first, surgical memoization, virtualization, code splitting, colocation and transitions.

Performance Is a Feature

Slow UIs cost money. A 100ms delay in load time can reduce conversion rates by 1%. A janky scroll experience drives users away. Performance is not a nice-to-have — it's a product requirement.

But optimization done wrong wastes engineering time and adds complexity with no measurable gain. This guide focuses on changes that actually move the needle.

Step 0: Profile Before You Optimize

Every performance fix should start with measurement. React DevTools Profiler and Chrome Performance tab are your best friends.

tstypescript
// Enable profiling in production builds
// next.config.ts
const nextConfig = {
  experimental: {
    reactCompiler: true, // React 19+
  },
  productionBrowserSourceMaps: true,
};

Record a performance trace, identify the components with the longest render times, and fix those first. Optimizing a component that renders in 2ms is wasted effort.

Pattern 1: Memoization — Use It Surgically

React.memo, useMemo, and useCallback have a cost: the comparison function runs every render. Memoization only wins when the component is expensive AND the props are stable.

tstypescript
// ❌ Over-memoizing cheap components — adds overhead, no benefit
const SimpleLabel = React.memo(({ text }: { text: string }) => (
  <span>{text}</span>
));
 
// ✅ Memoize expensive computations
const DataGrid = React.memo(({ rows, columns }: DataGridProps) => {
  return <VirtualizedTable rows={rows} columns={columns} />;
}, (prev, next) => {
  // Custom comparison — shallow equality isn't enough for arrays
  return prev.rows === next.rows && prev.columns === next.columns;
});
 
// ✅ Stabilize callbacks passed to memoized children
function Dashboard({ userId }: { userId: string }) {
  const handleRowClick = useCallback((rowId: string) => {
    router.push(`/items/${rowId}`);
  }, []); // No dependencies — stable across renders
 
  return <DataGrid rows={rows} onRowClick={handleRowClick} />;
}

Rule of thumb: memoize when you can measure the improvement.

Pattern 2: Virtualization for Long Lists

Rendering 10,000 DOM nodes is the fastest way to kill performance. Virtualization renders only visible items.

tstypescript
import { useVirtualizer } from "@tanstack/react-virtual";
 
function VirtualList({ items }: { items: Item[] }) {
  const parentRef = useRef<HTMLDivElement>(null);
 
  const virtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 64, // estimated row height in px
    overscan: 5, // render 5 extra items above/below viewport
  });
 
  return (
    <div ref={parentRef} style={{ height: "600px", overflow: "auto" }}>
      <div style={{ height: `${virtualizer.getTotalSize()}px`, position: "relative" }}>
        {virtualizer.getVirtualItems().map((virtualItem) => (
          <div
            key={virtualItem.key}
            style={{
              position: "absolute",
              top: 0,
              transform: `translateY(${virtualItem.start}px)`,
              width: "100%",
              height: `${virtualItem.size}px`,
            }}
          >
            <ListRow item={items[virtualItem.index]} />
          </div>
        ))}
      </div>
    </div>
  );
}

For 10,000 items: rendering drops from ~8 seconds to ~50ms. This is not marginal — it's transformational.

Pattern 3: Code Splitting and Lazy Loading

Every kilobyte of JavaScript blocks rendering. Split your bundle at route and component boundaries.

tstypescript
import { lazy, Suspense } from "react";
 
// Route-level splitting — Next.js handles this automatically
// Component-level splitting for heavy, conditionally rendered components
const RichTextEditor = lazy(() => import("@/components/RichTextEditor"));
const DataVisualization = lazy(() => import("@/components/DataVisualization"));
 
function ArticleEditor({ article }: { article: Article }) {
  const [showPreview, setShowPreview] = useState(false);
 
  return (
    <div>
      <Suspense fallback={<EditorSkeleton />}>
        <RichTextEditor content={article.content} />
      </Suspense>
 
      {showPreview && (
        <Suspense fallback={<div>Loading preview...</div>}>
          <DataVisualization data={article.metrics} />
        </Suspense>
      )}
    </div>
  );
}

Combined with next/dynamic, this gives you fine-grained control over what ships in the initial bundle.

Pattern 4: State Colocation

Global state causes global re-renders. The fix is often architectural, not a React API call.

tstypescript
// ❌ Storing UI state in a global store — every subscriber re-renders
const useStore = create((set) => ({
  isDropdownOpen: false,
  toggleDropdown: () => set((s) => ({ isDropdownOpen: !s.isDropdownOpen })),
}));
 
// ✅ Keep UI state local to the component that owns it
function SearchBar() {
  const [isOpen, setIsOpen] = useState(false); // Only SearchBar re-renders
 
  return (
    <div>
      <input onFocus={() => setIsOpen(true)} onBlur={() => setIsOpen(false)} />
      {isOpen && <SearchSuggestions />}
    </div>
  );
}

Ask: "Who actually needs to know about this state?" If the answer is one component, keep it local.

Pattern 5: Transitions for Non-Urgent Updates

React 18's useTransition marks updates as non-urgent, keeping the UI responsive during expensive renders.

tstypescript
import { useTransition, useState } from "react";
 
function SearchPage() {
  const [query, setQuery] = useState("");
  const [results, setResults] = useState<Result[]>([]);
  const [isPending, startTransition] = useTransition();
 
  function handleSearch(value: string) {
    setQuery(value); // Urgent: update input immediately
 
    startTransition(() => {
      // Non-urgent: can be interrupted if user types again
      setResults(filterLargeDataset(value));
    });
  }
 
  return (
    <div>
      <input value={query} onChange={(e) => handleSearch(e.target.value)} />
      {isPending && <Spinner />}
      <ResultList results={results} />
    </div>
  );
}

The result: the input stays crisp even while the result list is computing.

Measuring Success

Before shipping any optimization:

MetricBeforeAfterTool
Time to Interactive4.2s1.8sLighthouse
Largest Contentful Paint3.1s1.2sChrome DevTools
Component render time320ms18msReact Profiler
Bundle size (initial)840KB280KBnext build

If you can't fill in this table, you haven't finished the optimization.

The Golden Rule

Performance work that ships is worth more than perfect work that doesn't. Start with the biggest wins — virtualization and code splitting typically deliver 10x improvements. Memoization is the last 10%.

Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX