Skip to main content

Performance Optimization in React

Published: May 24, 2026 Updated: August 30, 2026 Larry Qu 8 min read

React performance optimization has a common failure mode: developers apply React.memo, useMemo, and useCallback preemptively to every component and callback, then wonder why performance hasn’t improved — or has gotten worse. These tools have overhead. Adding them without measuring first often makes things slower, not faster.

The correct workflow is: measure with the React Profiler to identify actual bottlenecks, then apply targeted fixes. This article covers both the tools and the judgment to know when to use them.

Understanding React’s Rendering Model

React re-renders a component when its state or props change. By default, when a parent re-renders, all its children re-render too — even if their props are identical. For most applications, this is fine because React’s reconciliation is fast. The cases where it becomes a problem:

  • Expensive computations inside render — sorting/filtering large arrays, complex calculations
  • Large component trees where a root state change causes hundreds of unnecessary child renders
  • Lists with many items where each item does significant work per render
  • Callback-heavy components where function identity changes on every render, breaking memoization downstream

Profiling First

The React DevTools Profiler is the right starting point. It records render times for each component during an interaction, showing you exactly which components are slow and why.

Open React DevTools → Profiler tab → Record → do the slow interaction → Stop.

The flamegraph shows each component’s render time and how many times it rendered. Look for:

  • Components with unexpectedly high render counts
  • Renders where “Why did this render?” shows prop identity changes for equal values
  • The same component re-rendering multiple times in one interaction

The Profiler API lets you capture this programmatically for CI or production monitoring:

import { Profiler } from 'react';

// onRender callback receives: id, phase, actualDuration, baseDuration, startTime, commitTime
function onRender(id, phase, actualDuration) {
  if (actualDuration > 16) {
    // Log components that take longer than one frame (16ms at 60fps)
    console.warn(`Slow render: ${id} took ${actualDuration.toFixed(1)}ms in ${phase} phase`);
  }
}

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

React.memo: Skipping Re-renders

React.memo wraps a component and skips re-rendering when its props haven’t changed (using shallow equality). It’s effective when a component renders with the same props during parent re-renders.

Use it for components that:

  • Receive stable, non-changing props from a frequently-updating parent
  • Do meaningful work per render (complex JSX, data formatting)

Don’t use it for:

  • Simple components that render quickly — the comparison overhead may exceed the render cost
  • Components whose props always change on parent re-render anyway
import { memo } from 'react';

// Without memo: re-renders every time UserList renders, even if this user's data is unchanged
// With memo: skips re-render if user and onSelect references are the same
const UserCard = memo(function UserCard({ user, onSelect }) {
  return (
    <li onClick={() => onSelect(user.id)}>
      <strong>{user.name}</strong>
      <span>{user.email}</span>
    </li>
  );
});

For objects, the default shallow comparison checks reference identity, not deep equality. If the parent creates a new object literal on every render, memo won’t help — use useCallback and useMemo to stabilize those references.

Custom comparison for finer control:

const UserCard = memo(
  function UserCard({ user, onSelect }) { /* ... */ },
  (prev, next) => prev.user.id === next.user.id && prev.user.updatedAt === next.user.updatedAt
);

Use custom comparators carefully — an incorrect comparison that returns true when it shouldn’t will cause stale renders.

useMemo: Caching Expensive Computations

useMemo caches the result of a computation and recalculates only when its dependencies change. Use it when the computation is genuinely expensive and runs inside a component that re-renders frequently.

The classic case — filtering and sorting a large list:

import { useMemo, useState } from 'react';

function UserDashboard({ users }) {
  const [filter, setFilter] = useState('');
  const [sortBy, setSortBy] = useState('name');

  // Without useMemo: this runs on every render, including renders caused by unrelated state
  // With useMemo: only recalculates when users, filter, or sortBy actually change
  const displayedUsers = useMemo(() => {
    const filtered = filter
      ? users.filter(u => u.name.toLowerCase().includes(filter.toLowerCase()))
      : users;

    return [...filtered].sort((a, b) =>
      a[sortBy] < b[sortBy] ? -1 : a[sortBy] > b[sortBy] ? 1 : 0
    );
  }, [users, filter, sortBy]);

  return (
    <div>
      <input value={filter} onChange={e => setFilter(e.target.value)} placeholder="Search..." />
      {displayedUsers.map(user => <UserCard key={user.id} user={user} />)}
    </div>
  );
}

useMemo is often misused for object identity stabilization — creating objects to pass to child components so their references don’t change. This is a valid use case, but only when the child is wrapped in memo:

// Only useful if ChildComponent is wrapped with memo AND config identity matters
const config = useMemo(() => ({ theme, density }), [theme, density]);
return <ChildComponent config={config} />;

If ChildComponent isn’t memoized, stabilizing config’s identity has no effect.

useCallback: Stabilizing Function References

Functions are recreated on every render. When you pass a callback to a memoized child, the new function reference breaks memoization even if the behavior is identical. useCallback caches the function reference.

The important insight: useCallback only matters when the callback is passed to a memo-wrapped component or used as a useEffect dependency. Wrapping every handler in useCallback unconditionally adds overhead without benefit.

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

// Memoized child — will skip re-renders if onDelete reference is stable
const UserCard = memo(function UserCard({ user, onDelete }) {
  console.log('UserCard rendered:', user.id);
  return <button onClick={() => onDelete(user.id)}>{user.name}</button>;
});

function UserList({ users }) {
  const [selected, setSelected] = useState(null);

  // Without useCallback: new function on every render → all UserCard memo optimizations are wasted
  // With useCallback: stable reference as long as no dependencies change
  const handleDelete = useCallback((userId) => {
    // setUsers call would go here
    console.log('Deleting user:', userId);
  }, []); // No dependencies — function never changes

  return (
    <ul>
      {users.map(user => (
        <UserCard key={user.id} user={user} onDelete={handleDelete} />
      ))}
    </ul>
  );
}

Code Splitting and Lazy Loading

The second major performance lever is reducing initial bundle size. React.lazy and Suspense let you split route-level components into separate chunks that download on demand:

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

// Each import() becomes a separate JS chunk loaded only when navigated to
const Dashboard  = lazy(() => import('./pages/Dashboard'));
const AdminPanel = lazy(() => import('./pages/AdminPanel'));
const Analytics  = lazy(() => import('./pages/Analytics'));

function App() {
  return (
    <Suspense fallback={<PageSkeleton />}>
      <Routes>
        <Route path="/dashboard" element={<Dashboard />} />
        <Route path="/admin"     element={<AdminPanel />} />
        <Route path="/analytics" element={<Analytics />} />
      </Routes>
    </Suspense>
  );
}

For heavy third-party libraries used in only some components, lazy-import them too:

// ✅ The chart library only loads when this component renders
const ChartComponent = lazy(() =>
  import('./components/ChartComponent')
    .then(module => ({ default: module.ChartComponent }))
);

Check your bundle with webpack-bundle-analyzer or Vite’s rollup-plugin-visualizer to find large dependencies worth splitting.

Virtual Lists for Large Data

Rendering thousands of DOM nodes is the most straightforward path to a laggy UI. Virtual lists (also called windowing) render only the items visible in the viewport — typically 20–50 rows — regardless of how many items are in the data set.

react-window is the standard library for this:

npm install react-window
import { FixedSizeList } from 'react-window';

function VirtualUserList({ users }) {
  // Row renderer — receives index and style from react-window
  // style MUST be applied to the row element for correct positioning
  const Row = ({ index, style }) => (
    <div style={style} className="user-row">
      <UserCard user={users[index]} />
    </div>
  );

  return (
    <FixedSizeList
      height={600}
      width="100%"
      itemCount={users.length}
      itemSize={72}     // Height of each row in pixels
    >
      {Row}
    </FixedSizeList>
  );
}

For items with variable heights, use VariableSizeList with a function that returns the size for each index. For two-dimensional grids, FixedSizeGrid from the same library handles columns and rows.

Image Optimization

Images are often the largest contributors to page weight. Three practices make a significant difference:

// Lazy-load below-the-fold images — the browser defers loading until they near the viewport
function UserAvatar({ src, name }) {
  return <img src={src} alt={name} loading="lazy" decoding="async" />;
}

// Serve WebP with a JPEG fallback for older browsers
function OptimizedImage({ src, alt, width, height }) {
  return (
    <picture>
      <source srcSet={`${src}.webp`} type="image/webp" />
      <img
        src={`${src}.jpg`}
        alt={alt}
        width={width}
        height={height}     // Prevents layout shift — reserve space before image loads
        loading="lazy"
      />
    </picture>
  );
}

Always specify width and height on images. Without them, the browser doesn’t know how much space to reserve, causing layout shift (CLS) when the image loads.

State Architecture and Unnecessary Re-renders

Many performance problems come from state placement, not from needing more memoization. Common patterns:

Colocate state — state that only affects one subtree should live in that subtree, not at the root. State at the root re-renders the entire tree on every change.

Split context — if you have a context with both frequently-changing values (like a theme) and rarely-changing values (like user info), split them into two separate contexts. Every consumer re-renders when any context value changes.

// ❌ One large context re-renders all consumers on any change
const AppContext = createContext({ user, theme, notifications, settings });

// ✅ Separate contexts — components subscribe only to what they need
const UserContext   = createContext(user);
const ThemeContext  = createContext(theme);

Use refs for non-display values — if you need to track a value across renders but it doesn’t affect the UI (like a timer ID or previous value), use useRef instead of useState. Ref updates don’t trigger re-renders.

Summary

Effective React optimization follows a sequence:

  1. Measure with the React Profiler or DevTools — identify real bottlenecks before adding memoization
  2. Apply React.memo to components that receive stable props but re-render unnecessarily
  3. Use useCallback and useMemo to stabilize the prop references that memo relies on
  4. Split route-level code with React.lazy to reduce initial bundle size
  5. Use virtual lists for any list that could exceed ~100 items
  6. Review state placement — colocating state and splitting large contexts often fixes more than memoization

Resources

Comments

👍 Was this article helpful?