React Performance Optimization: Memoization, Code Splitting and More (2026)

Performance is a feature. A slow React app loses users — 100ms of latency can reduce conversions by 7%. Yet React apps commonly ship with unnecessary re-renders, bloated bundles, and unvirtualized lists that freeze on mobile devices. This guide walks through every major performance lever in modern React, with before/after examples and measurable improvements. You'll learn when to apply each technique and, just as importantly, when not to.

React.memo: Skipping Child Re-renders

By default, every time a parent re-renders, all its children re-render too — even if their props didn't change. React.memo wraps a component and performs a shallow prop comparison. If props are the same, the render is skipped entirely.

// ❌ Before: ProductCard re-renders every time Cart re-renders
function Cart({ items, discount }) {
  const [coupon, setCoupon] = useState('');

  return (
    <div>
      <input value={coupon} onChange={e => setCoupon(e.target.value)} />
      {items.map(item => (
        <ProductCard key={item.id} item={item} discount={discount} />
      ))}
    </div>
  );
}

// ✅ After: ProductCard only re-renders when item or discount changes
const ProductCard = React.memo(({ item, discount }) => {
  console.log('Rendering:', item.name);
  return (
    <div className="card">
      <h3>{item.name}</h3>
      <p>${(item.price * (1 - discount)).toFixed(2)}</p>
    </div>
  );
});

// Custom comparison function for deep equality or specific fields
const HeavyChart = React.memo(
  ({ data, title }) => <Chart data={data} title={title} />,
  (prevProps, nextProps) =>
    prevProps.title === nextProps.title &&
    prevProps.data.length === nextProps.data.length
);
Note: React.memo only does a shallow comparison. If you pass new object or array literals on every render, memo won't help unless you also stabilize those props with useMemo/useCallback.

useMemo and useCallback

These hooks prevent expensive recalculations and unstable function references that break React.memo.

// ❌ Problem: handleDelete is a new function every render
// Even though ExpensiveList is wrapped in React.memo,
// it will re-render because handleDelete reference changes.
function Dashboard({ users }) {
  const [search, setSearch] = useState('');

  const filtered = users.filter(u =>
    u.name.toLowerCase().includes(search.toLowerCase())
  ); // recalculated on every keystroke, even for unrelated state

  const handleDelete = (id) => deleteUser(id); // new ref every render

  return <UserList users={filtered} onDelete={handleDelete} />;
}

// ✅ Fixed: stable references, memoized computation
function Dashboard({ users }) {
  const [search, setSearch] = useState('');

  const filtered = useMemo(
    () => users.filter(u =>
      u.name.toLowerCase().includes(search.toLowerCase())
    ),
    [users, search]
  );

  const handleDelete = useCallback((id) => deleteUser(id), []);

  return <UserList users={filtered} onDelete={handleDelete} />;
}
Tip: Run the React DevTools Profiler before adding memoization. In many cases the re-render is cheap and memoization adds more overhead than it saves. Optimize only what you can measure.

Code Splitting with React.lazy and Suspense

Code splitting breaks your app bundle into smaller chunks that load on demand. Without it, users download the entire application JavaScript before seeing anything. React.lazy combined with dynamic import() makes route-level splitting trivial.

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

// Each page is loaded only when the route is visited
const Home = lazy(() => import('./pages/Home'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
const Analytics = lazy(() => import('./pages/Analytics'));

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

// PageSkeleton: show something while the chunk loads
function PageSkeleton() {
  return (
    <div className="container mt-5">
      <div className="skeleton-line" style={{ height: 32, width: '60%' }} />
      <div className="skeleton-line mt-3" style={{ height: 16, width: '90%' }} />
      <div className="skeleton-line mt-2" style={{ height: 16, width: '80%' }} />
    </div>
  );
}

Dynamic Imports for Routes and Heavy Libraries

You can lazy-load more than just routes. Heavy libraries like chart libraries, rich text editors, or PDF generators can be imported dynamically so they don't block initial load.

// Lazy-load a heavy charting library only when needed
async function loadChart() {
  const { Chart } = await import('chart.js');
  return Chart;
}

// In a component — load on user interaction
function ReportPage() {
  const [ChartComponent, setChartComponent] = useState(null);

  const handleShowChart = async () => {
    const { default: LazyChart } = await import('./HeavyChart');
    setChartComponent(() => LazyChart);
  };

  return (
    <div>
      <button onClick={handleShowChart}>Show Chart</button>
      {ChartComponent && <ChartComponent />}
    </div>
  );
}

// Prefetch a route chunk on hover for faster perceived navigation
function NavLink({ to, children }) {
  const handleMouseEnter = () => {
    // Vite/webpack will start downloading the chunk
    import(`./pages/${to}`);
  };

  return (
    <a href={to} onMouseEnter={handleMouseEnter}>{children}</a>
  );
}

List Virtualization with react-window

Rendering 10,000 list items creates 10,000 DOM nodes. The browser becomes unresponsive. Virtualization only renders the items currently visible in the viewport, keeping DOM nodes at a constant ~20-50 regardless of list size.

import { FixedSizeList as List } from 'react-window';

// ❌ Before: renders all 50,000 rows
function BadUserList({ users }) {
  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>{user.name} — {user.email}</li>
      ))}
    </ul>
  );
}

// ✅ After: only renders ~15 rows at a time
const Row = ({ index, style, data }) => (
  <div style={style} className="list-row">
    {data[index].name} — {data[index].email}
  </div>
);

function VirtualUserList({ users }) {
  return (
    <List
      height={600}        // visible container height
      itemCount={users.length}
      itemSize={50}       // each row height in px
      width="100%"
      itemData={users}
    >
      {Row}
    </List>
  );
}

// Variable-height items: use VariableSizeList
import { VariableSizeList } from 'react-window';

function DynamicList({ posts }) {
  const getItemSize = (index) => posts[index].content.length > 200 ? 120 : 60;

  return (
    <VariableSizeList
      height={500}
      itemCount={posts.length}
      itemSize={getItemSize}
      width="100%"
    >
      {({ index, style }) => (
        <div style={style}>{posts[index].title}</div>
      )}
    </VariableSizeList>
  );
}

Diagnosing and Avoiding Unnecessary Re-renders

Several common patterns cause cascading re-renders. Knowing the culprits saves hours of debugging.

// ❌ Pattern 1: Inline object props — new reference every render
<Chart options={{ color: 'blue', size: 'large' }} />

// ✅ Fix: define outside component or use useMemo
const chartOptions = { color: 'blue', size: 'large' }; // outside component
// OR inside component:
const chartOptions = useMemo(() => ({ color, size }), [color, size]);

// ❌ Pattern 2: Inline arrow function — new reference every render
<Button onClick={() => handleClick(item.id)} />

// ✅ Fix for simple case: useCallback
const handleItemClick = useCallback(() => handleClick(item.id), [item.id]);

// ❌ Pattern 3: Context value object — ALL consumers re-render on any state change
function AppProvider({ children }) {
  const [user, setUser] = useState(null);
  const [theme, setTheme] = useState('dark');

  return (
    <AppContext.Provider value={{ user, theme, setUser, setTheme }}>
      {children}
    </AppContext.Provider>
  );
}

// ✅ Fix: split into separate contexts
<UserContext.Provider value={{ user, setUser }}>
  <ThemeContext.Provider value={{ theme, setTheme }}>
    {children}
  </ThemeContext.Provider>
</UserContext.Provider>

React DevTools Profiler

The React DevTools Profiler records which components rendered, how long each render took, and why the render happened. Install the browser extension, open DevTools → Profiler tab, click Record, interact with your app, then stop recording.

Key things to look for in the flame graph:

  • Wide bars — components that take a long time to render (optimize these first)
  • "Why did this render?" — click a bar to see if it was props, state, context, or a parent re-render
  • Grey bars — components that were skipped thanks to memo (good!)
  • Repeated renders — if a component renders 50 times during a single interaction, investigate
// Built-in Profiler component for custom metrics
import { Profiler } from 'react';

function onRenderCallback(
  id,         // component tree that just committed
  phase,      // "mount" or "update"
  actualDuration,  // ms spent rendering the committed update
  baseDuration,    // estimated ms to render without memoization
  startTime,
  commitTime
) {
  if (actualDuration > 16) { // flag renders slower than 60fps budget
    console.warn(`Slow render in ${id}: ${actualDuration.toFixed(1)}ms`);
  }
}

<Profiler id="Dashboard" onRender={onRenderCallback}>
  <Dashboard />
</Profiler>

Bundle Analysis and Image Optimization

A common cause of slow React apps is a bloated JavaScript bundle. Use vite-bundle-visualizer or webpack-bundle-analyzer to find large dependencies.

# Vite — analyze bundle
npm run build -- --mode analyze
# or use rollup-plugin-visualizer in vite.config.ts:
import { visualizer } from 'rollup-plugin-visualizer';
plugins: [react(), visualizer({ open: true })]

# webpack — analyze bundle
npx webpack-bundle-analyzer build/static/js/*.js

Common bundle savings: replace moment.js (330KB) with date-fns (tree-shakeable), replace lodash with lodash-es or native methods, and avoid importing entire icon libraries — import individual icons instead.

// ❌ Imports entire lodash (70KB+)
import _ from 'lodash';
const debounced = _.debounce(fn, 300);

// ✅ Import only what you need
import debounce from 'lodash-es/debounce';

// ❌ Imports ALL icons from lucide-react
import * as Icons from 'lucide-react';

// ✅ Tree-shaken — only Search and Menu are bundled
import { Search, Menu } from 'lucide-react';

// Image optimization with Next.js Image component
import Image from 'next/image';
// ✅ Automatic WebP conversion, lazy loading, blur placeholder
<Image
  src="/hero.jpg"
  width={1200}
  height={600}
  alt="Hero"
  priority       // LCP image — load eagerly
  placeholder="blur"
  blurDataURL="data:image/jpeg;base64,..."
/>

Frequently Asked Questions

Should I wrap every component in React.memo?

No. React.memo adds overhead from prop comparison. Only memoize components that are expensive to render AND whose props frequently stay the same while their parent re-renders. Use the Profiler to identify actual bottlenecks first. Over-memoizing can make code harder to read without measurable benefit.

What is the difference between code splitting and tree shaking?

Tree shaking removes unused code from your bundle at build time (dead code elimination). Code splitting keeps all code but loads it in separate chunks on demand at runtime. Both reduce what the user downloads, but tree shaking happens statically while code splitting defers loading dynamically.

How many items require list virtualization?

As a rule of thumb, virtualize when rendering more than 200–500 items. Below that, the overhead of react-window (setup, scroll math, item measurement) usually outweighs the DOM savings. For 50–200 items, pagination is often simpler and equally effective.

Does useMemo prevent re-renders?

No — useMemo prevents expensive recalculations but does not prevent the component from re-rendering. To prevent re-renders, use React.memo on child components. useMemo helps React.memo work correctly by stabilizing object/array references passed as props.

What is the biggest source of React performance problems in production?

Typically one of three things: (1) large unvirtualized lists, (2) context that causes entire subtrees to re-render on every update, or (3) synchronous heavy computation blocking the main thread on each render. Profile first, fix the biggest bottleneck, measure again.