Frontend

React Performance Optimization Tips

Mayur Dabhi
Mayur Dabhi
April 7, 2026
14 min read

You type into a search box and the characters show up a beat late. Or a dashboard that felt fine with 50 rows starts stuttering once real data has 5,000. Usually nothing is broken. Components are re-rendering when they don't need to, an expensive filter runs on every keystroke, or the first page load pulls down one huge bundle.

This post covers the fixes I reach for in that situation: memoization with React.memo, useMemo and useCallback, code splitting, list virtualization, state placement, profiling, and images. For each one I'll also say where it doesn't help.

Measure Before You Optimize

Don't guess. Open the React DevTools Profiler (and Chrome's Performance panel if the problem isn't React) and find the component that's actually slow. Memoizing the wrong thing adds code and fixes nothing.

Understanding React's Rendering Behavior

Most performance work in React comes down to knowing why a component rendered. A component re-renders when:

A re-render on its own is usually cheap. The diffing is fast. It gets expensive when each render does heavy computation, when it drags a large child tree along with it, or when it happens very often (every keystroke, every mouse move, every scroll event).

State / Props Change Render Phase Virtual DOM Diff Commit Phase Update Real DOM Browser Paint Bailout (memo / PureComponent) useEffect runs after

React's Render → Commit → Paint cycle, and where memoization creates a bailout

Memoization: React.memo, useMemo, useCallback

Memoization means caching a result so you don't recalculate it. React gives you three tools for it, and they solve different problems.

React.memo: Skip Re-renders When Props Didn't Change

Wrap a component in React.memo and React will skip rendering it when its props are shallowly equal to last time. That pays off when a parent re-renders a lot but keeps passing the same props to a costly child.

React.memo: Basic Usage
// Without memo, this re-renders every time parent re-renders
function ExpensiveChart({ data }) {
    console.log('Chart rendered');
    return <div>{/* heavy chart rendering */}</div>;
}

// With memo, it only re-renders when `data` prop changes
const ExpensiveChart = React.memo(function ExpensiveChart({ data }) {
    console.log('Chart rendered');
    return <div>{/* heavy chart rendering */}</div>;
});

// Custom comparison function for deep equality
const ExpensiveChart = React.memo(
    function ExpensiveChart({ data, config }) {
        return <div>{/* ... */}</div>;
    },
    (prevProps, nextProps) => {
        // Return true to SKIP re-render (props are "equal")
        return (
            prevProps.data.id === nextProps.data.id &&
            prevProps.config.theme === nextProps.config.theme
        );
    }
);
Object Identity Pitfall

React.memo compares props shallowly. Pass an object literal like style={{ color: 'red' }} or an inline function like onClick={() => doSomething()} and you get a new reference on every render, so memo never skips anything. If the child is memoized, stabilize those props with useMemo or useCallback. The same goes for children: JSX passed as children is a new object each render, which quietly breaks memo on wrapper components.

useCallback: Keep Function References Stable

useCallback hands back the same function between renders until one of its dependencies changes. Without it, every render creates a new function, and a React.memo child that receives it re-renders anyway.

useCallback: Stabilizing Event Handlers
import { useState, useCallback } from 'react';

function ParentComponent() {
    const [count, setCount] = useState(0);
    const [text, setText] = useState('');

    // BAD: new function reference on every render
    // const handleDelete = (id) => deleteItem(id);

    // GOOD: stable reference, only changes if deleteItem changes
    const handleDelete = useCallback((id) => {
        deleteItem(id);
    }, []); // empty deps = never changes

    // With dependencies
    const handleSearch = useCallback((query) => {
        fetchResults(query, { userId: currentUser.id });
    }, [currentUser.id]); // re-creates only when user changes

    return (
        <>
            <input value={text} onChange={e => setText(e.target.value)} />
            <p>Count: {count}</p>
            <button onClick={() => setCount(c => c + 1)}>+</button>
            {/* ItemList won't re-render when count or text changes */}
            <ItemList onDelete={handleDelete} />
        </>
    );
}

const ItemList = React.memo(({ onDelete }) => {
    console.log('ItemList rendered');
    return <ul>{/* items */}</ul>;
});

useMemo: Cache Expensive Computations

Where useCallback caches a function, useMemo caches the result of calling one. Reach for it when a derived value is slow to compute and its inputs don't change often.

useMemo: Caching Derived Data
import { useMemo, useState } from 'react';

function ProductList({ products, filters }) {
    const [sortBy, setSortBy] = useState('price');

    // BAD: recalculates on every render, even on unrelated state changes
    // const filteredProducts = products
    //     .filter(p => p.category === filters.category)
    //     .sort((a, b) => a[sortBy] - b[sortBy]);

    // GOOD: only recalculates when products, filters, or sortBy changes
    const filteredProducts = useMemo(() => {
        console.log('Filtering and sorting products...');
        return products
            .filter(p => p.category === filters.category && p.price <= filters.maxPrice)
            .sort((a, b) => {
                if (sortBy === 'price') return a.price - b.price;
                if (sortBy === 'name') return a.name.localeCompare(b.name);
                return b.rating - a.rating;
            });
    }, [products, filters.category, filters.maxPrice, sortBy]);

    return (
        <div>
            <select value={sortBy} onChange={e => setSortBy(e.target.value)}>
                <option value="price">Price</option>
                <option value="name">Name</option>
                <option value="rating">Rating</option>
            </select>
            {filteredProducts.map(p => <ProductCard key={p.id} product={p} />)}
        </div>
    );
}
Hook / API What It Memoizes When to Use
React.memo Entire component render output Child component with stable props that re-renders due to parent
useCallback Function reference Callbacks passed as props to memoized children or used in useEffect deps
useMemo Computed value Expensive calculations (filtering, sorting, mapping large arrays)

Code Splitting and Lazy Loading

If every page of the app ships in one bundle, a user opening the login screen also downloads your admin panel and your charting library. Code splitting breaks the app into chunks that load when they're needed, which cuts the JavaScript on first load.

React.lazy and Suspense

React.lazy gives you component-level splitting with no extra library. The first time a lazy component renders, React fetches its chunk, and the nearest Suspense boundary shows its fallback until it arrives.

React.lazy + Suspense: Route-Level Splitting
import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';

// Each page becomes a separate bundle loaded on demand
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Analytics = lazy(() => import('./pages/Analytics'));
const Settings = lazy(() => import('./pages/Settings'));
const AdminPanel = lazy(() => import('./pages/AdminPanel'));

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

// You can also nest Suspense for more granular control
function Dashboard() {
    const HeavyChart = lazy(() => import('../components/HeavyChart'));

    return (
        <div>
            <h1>Dashboard</h1>
            <Suspense fallback={<ChartSkeleton />}>
                <HeavyChart />
            </Suspense>
        </div>
    );
}
Two lazy() Gotchas

The nested example above declares HeavyChart inside Dashboard to keep it short. Don't do that in real code: every render of Dashboard creates a new lazy component, so React unmounts and remounts the chart and throws away its state. Declare lazy() calls at module level. Second, after a deploy, users with an old tab open can hit ChunkLoadError (or "Failed to fetch dynamically imported module" in Vite) because the old chunk file is gone. Wrap lazy routes in an error boundary that offers a reload, and keep the previous build's assets around for a while if you can.

Dynamic Imports for Non-Component Code

The same idea works for plain libraries. If something heavy is only needed after a click, import it inside the handler.

Dynamic Import: Heavy Library on Demand
// Don't import heavy libraries at the top of the file
// import { PDFDocument } from 'pdf-lib'; // ~400KB

async function handleExportPDF(data) {
    // Only loads pdf-lib when the user actually clicks "Export PDF"
    const { PDFDocument } = await import('pdf-lib');
    const pdfDoc = await PDFDocument.create();
    // ... generate PDF
}

// Same pattern works for chart libraries, parsers, etc.
async function renderChart(canvas, data) {
    const { Chart } = await import('chart.js/auto');
    new Chart(canvas, { type: 'line', data });
}

Virtualization: Rendering Large Lists

Thousands of DOM nodes are slow no matter how tight your JavaScript is. Once a list runs into the hundreds or thousands of rows, virtualization usually beats any amount of memoization. You only render the rows that are currently in the viewport, plus a few extra for smooth scrolling.

1

Install a virtualization library

The common choices are react-window (small, opinionated) and TanStack Virtual (@tanstack/react-virtual, headless and more flexible). react-window is the quickest to get going. Check which major version you install: the example below uses the v1 API, and v2 replaced FixedSizeList with a new List component.

2

Replace your map with a virtualized list

Wrap your list in FixedSizeList (uniform row heights) or VariableSizeList (dynamic heights). Only visible rows are mounted in the DOM.

react-window: Virtualizing 100,000 Rows
import { FixedSizeList } from 'react-window';

const ITEMS = Array.from({ length: 100_000 }, (_, i) => ({
    id: i,
    name: `User ${i}`,
    email: `user${i}@example.com`,
}));

// Row component: receives index and style from react-window
const Row = React.memo(({ index, style }) => (
    <div style={style} className="list-row">
        <span>{ITEMS[index].name}</span>
        <span>{ITEMS[index].email}</span>
    </div>
));

function UserList() {
    return (
        <FixedSizeList
            height={600}        // container height in px
            itemCount={ITEMS.length}
            itemSize={56}       // row height in px
            width="100%"
        >
            {Row}
        </FixedSizeList>
    );
}

// Result: ~10 DOM nodes in the list at any time
// instead of 100,000, which saves a lot of memory and paint work

One trade-off: rows that aren't rendered can't be found with the browser's Ctrl+F, and screen readers only see the mounted rows. For a searchable table of a few hundred rows, pagination is sometimes the better answer.

State Management Performance

More often than not, extra renders trace back to where state lives, not to missing memo calls. Keep state as close as you can to the components that read it, so an update only touches a small part of the tree.

Colocate State: Don't Lift It Without a Reason

State Colocation: Keep State Where It's Used
// BAD: search state in a top-level component
// causes entire App to re-render on every keystroke
function App() {
    const [search, setSearch] = useState('');
    return (
        <div>
            <Header />         {/* re-renders unnecessarily */}
            <Sidebar />        {/* re-renders unnecessarily */}
            <SearchBar value={search} onChange={setSearch} />
            <Results query={search} />
        </div>
    );
}

// GOOD: search state lives in the component that needs it
function SearchSection() {
    const [search, setSearch] = useState('');
    return (
        <div>
            <SearchBar value={search} onChange={setSearch} />
            <Results query={search} />
        </div>
    );
}

function App() {
    return (
        <div>
            <Header />       {/* never re-renders due to search */}
            <Sidebar />      {/* never re-renders due to search */}
            <SearchSection />
        </div>
    );
}

Context: Split by How Often Values Change

A pattern I see a lot is one big context holding values that change constantly (notifications) next to values that almost never change (theme). Any update re-renders every consumer, so the theme toggle re-renders whenever a notification arrives.

Split Context by Update Frequency
// BAD: one context for everything
const AppContext = createContext();
function AppProvider({ children }) {
    const [user, setUser] = useState(null);
    const [theme, setTheme] = useState('dark');
    const [notifications, setNotifications] = useState([]);
    // Any notification causes theme consumers to re-render!
    return (
        <AppContext.Provider value={{ user, theme, notifications, setNotifications }}>
            {children}
        </AppContext.Provider>
    );
}

// GOOD: separate contexts by how often they change
const ThemeContext = createContext();    // changes rarely
const UserContext = createContext();     // changes on login/logout
const NotificationContext = createContext(); // changes frequently

function AppProvider({ children }) {
    const [theme, setTheme] = useState('dark');
    const [user, setUser] = useState(null);
    const [notifications, setNotifications] = useState([]);

    return (
        <ThemeContext.Provider value={{ theme, setTheme }}>
            <UserContext.Provider value={{ user, setUser }}>
                <NotificationContext.Provider value={{ notifications, setNotifications }}>
                    {children}
                </NotificationContext.Provider>
            </UserContext.Provider>
        </ThemeContext.Provider>
    );
}

Splitting alone doesn't finish the job. Each provider above still builds a fresh value={{ ... }} object on every render of AppProvider, so its consumers re-render whenever the provider does. Wrap those value objects in useMemo if AppProvider re-renders for other reasons.

useReducer for Complex State Transitions

When several pieces of state change together, useReducer is usually cleaner than a handful of useState calls. The reducer is a pure function, so it's easy to test. And dispatch has a stable identity, which means you can pass it down (or wrap it in useCallback with empty deps, as below) without breaking memoized children.

useReducer: Shopping Cart State
const cartReducer = (state, action) => {
    switch (action.type) {
        case 'ADD_ITEM': {
            const existing = state.items.find(i => i.id === action.item.id);
            if (existing) {
                return {
                    ...state,
                    items: state.items.map(i =>
                        i.id === action.item.id
                            ? { ...i, quantity: i.quantity + 1 }
                            : i
                    ),
                };
            }
            return { ...state, items: [...state.items, { ...action.item, quantity: 1 }] };
        }
        case 'REMOVE_ITEM':
            return { ...state, items: state.items.filter(i => i.id !== action.id) };
        case 'CLEAR_CART':
            return { ...state, items: [] };
        default:
            return state;
    }
};

function Cart() {
    const [cart, dispatch] = useReducer(cartReducer, { items: [] });

    const addItem = useCallback((item) => dispatch({ type: 'ADD_ITEM', item }), []);
    const removeItem = useCallback((id) => dispatch({ type: 'REMOVE_ITEM', id }), []);

    const total = useMemo(
        () => cart.items.reduce((sum, i) => sum + i.price * i.quantity, 0),
        [cart.items]
    );

    return <div>{/* render */}</div>;
}

Real-World Pattern: Performant Data Table

A sortable, filterable table with thousands of rows is the classic case where all three tools earn their place, because each one covers a gap the others leave open.

1

Memoize the expensive data transformation

Filter and sort operations on large datasets should live in useMemo. They're expensive and depend on specific inputs, not every state change.

2

Wrap the Row component with React.memo

Rows are pure: same data in, same output. With React.memo, rows whose data didn't change are skipped when the filter or sort changes, instead of all of them re-rendering.

3

Stabilize row-level callbacks with useCallback

Wrap the onRowClick or onDelete handlers you pass to each row in useCallback. If you don't, every row gets a new function each render and React.memo never skips anything.

4

Measure before and after with React DevTools

Open the Profiler tab, record an interaction, and confirm the flamegraph shows fewer renders. If nothing improved, take the memoization back out. It's just overhead at that point.

DataTable.jsx: all three tools together
import { useState, useMemo, useCallback } from 'react';

// Step 2: Memoize the Row component
const TableRow = React.memo(function TableRow({ row, onDelete, onEdit }) {
  return (
    <tr>
      <td>{row.id}</td>
      <td>{row.name}</td>
      <td>{row.email}</td>
      <td>
        <button onClick={() => onEdit(row.id)}>Edit</button>
        <button onClick={() => onDelete(row.id)}>Delete</button>
      </td>
    </tr>
  );
});

function DataTable({ data }) {
  const [filterText, setFilterText] = useState('');
  const [sortField, setSortField] = useState('name');
  const [sortDir, setSortDir] = useState('asc');

  // Step 1: Memoize expensive transformation
  const processedData = useMemo(() => {
    const lower = filterText.toLowerCase();
    return data
      .filter(row =>
        row.name.toLowerCase().includes(lower) ||
        row.email.toLowerCase().includes(lower)
      )
      .sort((a, b) => {
        const aVal = a[sortField];
        const bVal = b[sortField];
        const cmp = aVal < bVal ? -1 : aVal > bVal ? 1 : 0;
        return sortDir === 'asc' ? cmp : -cmp;
      });
  }, [data, filterText, sortField, sortDir]);

  // Step 3: Stabilize callbacks
  const handleDelete = useCallback((id) => {
    console.log('Delete row:', id);
    // dispatch delete action...
  }, []);

  const handleEdit = useCallback((id) => {
    console.log('Edit row:', id);
    // open modal...
  }, []);

  return (
    <div>
      <input
        value={filterText}
        onChange={e => setFilterText(e.target.value)}
        placeholder="Filter rows..."
      />
      <p>Showing {processedData.length} of {data.length} rows</p>
      <table>
        <tbody>
          {processedData.map(row => (
            <TableRow
              key={row.id}
              row={row}
              onDelete={handleDelete}
              onEdit={handleEdit}
            />
          ))}
        </tbody>
      </table>
    </div>
  );
}

Debugging Memoization Issues

When memo "doesn't work", it's almost always one of these.

Common Memoization Bugs & Fixes

Symptom Root Cause Fix
Component always re-renders despite React.memo A prop (object, array, function) is a new reference each render Wrap the prop in useMemo or useCallback in the parent
useMemo recalculates on every render A dependency is a new object/array reference each time Stabilize the dependency with its own useMemo, or use primitive values
Stale closure: callback uses outdated state Missing dependency in useCallback deps array Add missing dependency, or use functional state update form setState(prev => ...)
Context value breaks all consumers' memo Context value is a new object value={{ a, b }} on every render Wrap the context value in useMemo: useMemo(() => ({ a, b }), [a, b])
ESLint warns about exhaustive-deps You have dependencies that should be in the deps array but aren't Trust the lint rule. Add the deps, then fix whatever stale closure issues it reveals

Using React DevTools Profiler

The Profiler is how you find out whether memoization did anything:

  1. Open React DevTools in your browser and switch to the Profiler tab
  2. Click Record and perform the interaction you want to speed up (e.g., type in a search field)
  3. Stop recording and look at the Flamegraph. Components that re-rendered are colored; skipped ones appear gray
  4. Click any component bar to see why it rendered (props changed, state changed, hooks changed, parent rendered). This is off by default: turn on "Record why each component rendered while profiling" in the Profiler settings (gear icon) first, or the panel won't show a reason
  5. After applying memo/useMemo/useCallback, repeat and compare the flamegraphs
Adding display names for better Profiler readability
// Anonymous memo components appear as "Anonymous" in DevTools
// Add displayName for clarity
const ProductCard = React.memo(function ProductCard(props) {
  return <div>...</div>;
});
// React automatically uses the function name as displayName ✓

// For arrow function components, set it manually:
const ProductCard = React.memo((props) => {
  return <div>...</div>;
});
ProductCard.displayName = 'ProductCard'; // ← required for arrow functions

// You can also use the why-did-you-render library for detailed logs:
// import whyDidYouRender from '@welldone-software/why-did-you-render';
// whyDidYouRender(React, { trackAllPureComponents: true });

Profiling and Measuring Performance

The Profiler tab in React DevTools records a session and shows which components rendered and how long each took. This is the workflow I use:

1

Install React DevTools

Add the React Developer Tools extension for Chrome or Firefox, open DevTools, and switch to the "Profiler" tab.

2

Record a session

Hit record, do the slow interaction, then stop. The flame chart lists every component that rendered in each commit and how long it took.

3

Find out why it rendered

First turn on "Record why each component rendered while profiling" in the Profiler settings (the gear icon). It's off by default, and without it you won't see the reason. Then click a component in the flame chart to see which props, state or hooks changed. That tells you what to fix.

4

Use the Profiler API in code

React's <Profiler> component reports render timings to a callback. Keep in mind it's disabled in normal production builds. To get numbers from production you need React's profiling build (react-dom/profiling), which adds some overhead.

React Profiler API: Logging Render Timings
import { Profiler } from 'react';

function onRenderCallback(
    id,           // the "id" prop of the Profiler tree that committed
    phase,        // "mount" or "update"
    actualDuration, // time spent rendering the committed update
    baseDuration,   // estimated time to re-render without memoization
    startTime,
    commitTime
) {
    if (actualDuration > 16) {
        // Log slow renders (16ms = 60fps budget)
        console.warn(`Slow render: ${id} took ${actualDuration.toFixed(2)}ms`);
        // Send to your monitoring service
        analytics.track('slow_render', { component: id, duration: actualDuration });
    }
}

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

Image and Asset Optimization

Not every slow page is a React problem. Images are often the heaviest thing on the page, and a few changes here help a lot:

Optimized Image Component with Lazy Loading
function OptimizedImage({ src, alt, width, height, priority = false }) {
    return (
        <img
            src={src}
            alt={alt}
            width={width}
            height={height}
            loading={priority ? 'eager' : 'lazy'}
            decoding="async"
            // Prevent layout shift by reserving space
            style={{ aspectRatio: `${width} / ${height}` }}
        />
    );
}

// For Next.js, use the built-in Image component which handles all of this
import Image from 'next/image';

function Hero() {
    return (
        <Image
            src="/hero.webp"
            alt="Hero image"
            width={1200}
            height={630}
            priority    // LCP image, so load eagerly
            quality={85}
        />
    );
}

When I'd Reach for Each Fix (and When I Wouldn't)

Not every technique here is worth its cost in every app. This is roughly how I decide:

Trade-offs Worth Knowing

  1. Moving state down is the first thing I try. It costs nothing at runtime and often makes memo unnecessary.
  2. Memoization only pays off when the Profiler shows a specific component re-rendering often and taking real time. On a cheap component, the dependency comparison can cost about as much as the render it skips. If you're on React 19 with the React Compiler enabled, most of this happens automatically, so hand-written useMemo and useCallback should be rare.
  3. Route-level code splitting is worth it once the bundle is large. For a small internal tool, the extra loading states and chunk errors after deploys can be more trouble than they save.
  4. Virtualization is for genuinely long lists. Skip it when users rely on Ctrl+F, when row heights are unpredictable and you don't want to measure them, or when 50 rows of pagination would do.
  5. Profiling in production needs the profiling build or Real User Monitoring. Don't ship <Profiler> wrappers and expect them to report anything from a normal production build.
Don't Memoize Everything

Wrapping every value in useMemo and every component in React.memo "just in case" costs memory for the cache and time for the comparisons, and it makes the code harder to read. It also breaks the moment someone passes an inline object. Add it where a profile shows a problem, then profile again to confirm it helped.

My usual loop: record a profile of the slow interaction, fix the single worst component, record again. Stop when the interaction feels fast on a mid-range phone, not on your development laptop.

React Performance Optimization useMemo useCallback React.memo Code Splitting
Mayur Dabhi

Mayur Dabhi

Full Stack Developer with 5+ years of experience building scalable web applications with Laravel, React, and Node.js.