Skip to content
Codeloom
React

React Performance Optimization

A practical guide to making React apps fast: memoization, virtualization, code splitting, the Profiler, and the patterns that actually matter in production.

·9 min read · By Codeloom
Intermediate 15 min read

What you'll learn

  • When and how to use React.memo, useMemo, and useCallback
  • List virtualization for large datasets
  • Code splitting with lazy and Suspense
  • How to use the React Profiler to find bottlenecks
  • Common performance pitfalls and how to avoid them

Prerequisites

  • Comfortable with React hooks and component lifecycle
  • Basic understanding of rendering and reconciliation

React is fast by default for most apps. The problems show up when components re-render too often, render too much DOM, or ship too much JavaScript. This guide covers the tools and patterns that fix those problems, in the order you should reach for them.

How React rendering works

Before optimizing, understand what triggers a render. A component re-renders when:

  1. Its state changes (via useState, useReducer)
  2. Its parent re-renders (props may or may not have changed)
  3. A context it consumes changes

A render does not mean the DOM updates. React diffs the virtual DOM against the previous render and only touches the real DOM where things changed. The cost you are optimizing is the JavaScript execution of the render function itself and the diffing process.

React.memo

React.memo wraps a component and skips re-rendering if its props have not changed (shallow comparison by default).

import { memo } from "react";

interface UserCardProps {
  name: string;
  email: string;
  role: string;
}

const UserCard = memo(function UserCard({ name, email, role }: UserCardProps) {
  console.log("UserCard rendered");
  return (
    <div className="user-card">
      <h3>{name}</h3>
      <p>{email}</p>
      <span className="badge">{role}</span>
    </div>
  );
});

When to use memo:

  • The component renders often but its props rarely change
  • The component is expensive to render (complex calculations, deep trees)
  • It sits under a parent that re-renders frequently

When not to bother:

  • The component is cheap to render
  • Props change on every render anyway
  • You have not measured and confirmed it is a bottleneck

You can also pass a custom comparison function as the second argument.

const ExpensiveList = memo(
  function ExpensiveList({ items, filter }: Props) {
    const filtered = items.filter(filter);
    return (
      <ul>
        {filtered.map((item) => (
          <li key={item.id}>{item.name}</li>
        ))}
      </ul>
    );
  },
  (prevProps, nextProps) => {
    // Only re-render if items array identity changed
    return prevProps.items === nextProps.items;
  }
);

useMemo

useMemo caches the result of an expensive computation between renders.

import { useMemo } from "react";

function AnalyticsDashboard({ transactions }: { transactions: Transaction[] }) {
  const stats = useMemo(() => {
    console.log("Computing stats...");
    return {
      total: transactions.reduce((sum, t) => sum + t.amount, 0),
      average: transactions.reduce((sum, t) => sum + t.amount, 0) / transactions.length,
      max: Math.max(...transactions.map((t) => t.amount)),
      byCategory: transactions.reduce((acc, t) => {
        acc[t.category] = (acc[t.category] || 0) + t.amount;
        return acc;
      }, {} as Record<string, number>),
    };
  }, [transactions]);

  return (
    <div>
      <p>Total: ${stats.total.toFixed(2)}</p>
      <p>Average: ${stats.average.toFixed(2)}</p>
      <p>Max: ${stats.max.toFixed(2)}</p>
      {Object.entries(stats.byCategory).map(([cat, amount]) => (
        <p key={cat}>{cat}: ${amount.toFixed(2)}</p>
      ))}
    </div>
  );
}

useMemo also stabilizes object and array references, which matters when passing them to memoized children.

function ParentComponent({ userId }: { userId: string }) {
  const [count, setCount] = useState(0);

  // Without useMemo, this creates a new object every render,
  // breaking React.memo on ChildComponent
  const config = useMemo(
    () => ({
      userId,
      theme: "dark",
      showDetails: true,
    }),
    [userId]
  );

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>Count: {count}</button>
      <MemoizedChild config={config} />
    </div>
  );
}

useCallback

useCallback is useMemo for functions. It returns the same function reference between renders unless its dependencies change.

import { useCallback, useState, memo } from "react";

function TodoApp() {
  const [todos, setTodos] = useState<Todo[]>([]);

  const addTodo = useCallback((text: string) => {
    setTodos((prev) => [...prev, { id: crypto.randomUUID(), text, done: false }]);
  }, []);

  const toggleTodo = useCallback((id: string) => {
    setTodos((prev) =>
      prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t))
    );
  }, []);

  const deleteTodo = useCallback((id: string) => {
    setTodos((prev) => prev.filter((t) => t.id !== id));
  }, []);

  return (
    <div>
      <AddTodoForm onAdd={addTodo} />
      <TodoList
        todos={todos}
        onToggle={toggleTodo}
        onDelete={deleteTodo}
      />
    </div>
  );
}

const TodoList = memo(function TodoList({
  todos,
  onToggle,
  onDelete,
}: {
  todos: Todo[];
  onToggle: (id: string) => void;
  onDelete: (id: string) => void;
}) {
  return (
    <ul>
      {todos.map((todo) => (
        <TodoItem
          key={todo.id}
          todo={todo}
          onToggle={onToggle}
          onDelete={onDelete}
        />
      ))}
    </ul>
  );
});

The pattern: useCallback for handler functions, memo on the child that receives them. One without the other does nothing useful.

List virtualization

When you have hundreds or thousands of items, rendering all of them is wasteful. Virtualization renders only the items currently visible in the viewport.

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

function VirtualizedList({ items }: { items: Item[] }) {
  const parentRef = useRef<HTMLDivElement>(null);

  const virtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 60, // estimated row height in px
    overscan: 5, // render 5 extra items above/below viewport
  });

  return (
    <div
      ref={parentRef}
      style={{ height: "500px", overflow: "auto" }}
    >
      <div
        style={{
          height: `${virtualizer.getTotalSize()}px`,
          width: "100%",
          position: "relative",
        }}
      >
        {virtualizer.getVirtualItems().map((virtualItem) => {
          const item = items[virtualItem.index];
          return (
            <div
              key={virtualItem.key}
              style={{
                position: "absolute",
                top: 0,
                left: 0,
                width: "100%",
                height: `${virtualItem.size}px`,
                transform: `translateY(${virtualItem.start}px)`,
              }}
            >
              <div className="p-3 border-b">
                <h4>{item.title}</h4>
                <p className="text-sm text-gray-500">{item.description}</p>
              </div>
            </div>
          );
        })}
      </div>
    </div>
  );
}

For a list of 10,000 items with a 500px viewport and 60px row height, virtualization renders about 14 DOM nodes instead of 10,000. The difference in initial render time is dramatic.

Code splitting with lazy and Suspense

Ship less JavaScript upfront by splitting your bundle at route or component boundaries.

import { lazy, Suspense, useState } from "react";

// These components are loaded on demand
const AdminPanel = lazy(() => import("./AdminPanel"));
const Analytics = lazy(() => import("./Analytics"));
const Settings = lazy(() => import("./Settings"));

function App() {
  const [tab, setTab] = useState("admin");

  return (
    <div>
      <nav>
        <button onClick={() => setTab("admin")}>Admin</button>
        <button onClick={() => setTab("analytics")}>Analytics</button>
        <button onClick={() => setTab("settings")}>Settings</button>
      </nav>

      <Suspense fallback={<div className="spinner">Loading...</div>}>
        {tab === "admin" && <AdminPanel />}
        {tab === "analytics" && <Analytics />}
        {tab === "settings" && <Settings />}
      </Suspense>
    </div>
  );
}

For route-level splitting with React Router:

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

const Home = lazy(() => import("./pages/Home"));
const Products = lazy(() => import("./pages/Products"));
const Checkout = lazy(() => import("./pages/Checkout"));

function App() {
  return (
    <Suspense fallback={<PageSkeleton />}>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/products" element={<Products />} />
        <Route path="/checkout" element={<Checkout />} />
      </Routes>
    </Suspense>
  );
}

Using the React Profiler

The Profiler tells you exactly which components rendered, how long they took, and why. Use the React DevTools Profiler tab or the programmatic <Profiler> component.

import { Profiler, ProfilerOnRenderCallback } from "react";

const onRender: ProfilerOnRenderCallback = (
  id,
  phase,
  actualDuration,
  baseDuration,
  startTime,
  commitTime
) => {
  if (actualDuration > 16) {
    console.warn(
      `Slow render: ${id} took ${actualDuration.toFixed(1)}ms (${phase})`
    );
  }
};

function App() {
  return (
    <Profiler id="App" onRender={onRender}>
      <Dashboard />
    </Profiler>
  );
}

In DevTools, record a profiling session, interact with your app, then examine the flame graph. Look for:

  • Components that render when they should not
  • Components with high “self time”
  • Large gaps between baseDuration and actualDuration (indicates memoization is helping)

State management patterns

Where you put state affects what re-renders. Lifting state up causes more components to re-render. Pushing state down limits the blast radius.

// Bad: search state lives too high, re-renders the entire list
function Page() {
  const [search, setSearch] = useState("");
  const [items, setItems] = useState(initialItems);

  return (
    <div>
      <input value={search} onChange={(e) => setSearch(e.target.value)} />
      <ExpensiveItemList items={items} />
    </div>
  );
}

// Good: extract the search input into its own component
function Page() {
  const [items, setItems] = useState(initialItems);

  return (
    <div>
      <SearchInput />
      <ExpensiveItemList items={items} />
    </div>
  );
}

function SearchInput() {
  const [search, setSearch] = useState("");
  return <input value={search} onChange={(e) => setSearch(e.target.value)} />;
}

When using Context, split contexts by update frequency. A single context that holds everything causes every consumer to re-render on any change.

import { createContext, useContext } from "react";

// Split contexts by what changes together
const ThemeContext = createContext<Theme>("light");
const UserContext = createContext<User | null>(null);
const CartContext = createContext<CartState>({ items: [], total: 0 });

// Components only re-render when their specific context changes
function Header() {
  const theme = useContext(ThemeContext);
  // Does NOT re-render when cart changes
  return <header className={theme}>...</header>;
}

Debouncing expensive updates

For inputs that trigger expensive operations (search, filtering), debounce the update.

import { useState, useDeferredValue, memo } from "react";

function SearchResults() {
  const [query, setQuery] = useState("");
  const deferredQuery = useDeferredValue(query);

  return (
    <div>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search..."
      />
      {/* Input stays responsive; results update when React has time */}
      <Results query={deferredQuery} />
    </div>
  );
}

const Results = memo(function Results({ query }: { query: string }) {
  const filtered = allItems.filter((item) =>
    item.name.toLowerCase().includes(query.toLowerCase())
  );

  return (
    <ul>
      {filtered.map((item) => (
        <li key={item.id}>{item.name}</li>
      ))}
    </ul>
  );
});

The optimization checklist

Before reaching for memo, useMemo, or useCallback:

  1. Measure first. Use the Profiler to identify actual bottlenecks. Do not optimize components that render in under 1ms.
  2. Fix the structure. Moving state down or extracting components often eliminates unnecessary renders without any memoization.
  3. Check your keys. Incorrect or missing keys in lists cause React to do more work during reconciliation.
  4. Avoid inline object/array literals in JSX when passing to memoized children. Each render creates a new reference.
  5. Virtualize long lists. If you render more than 50-100 items, consider virtualization.
  6. Split your bundle. Lazy load routes and heavy components.
  7. Then memoize the specific components and values that the Profiler shows as problems.

Performance optimization is about removing waste, not adding complexity. The best optimization is often deleting code or restructuring components, not wrapping everything in memo.