Frontend

React Performance Optimization Tips

Mayur Dabhi
Mayur Dabhi
April 7, 2026
14 min read

React is fast by default — but as your application grows in complexity, subtle inefficiencies can accumulate into noticeable sluggishness. Components re-rendering unnecessarily, expensive computations running on every keystroke, and huge JavaScript bundles blocking the main thread are all common culprits. The good news: React provides a rich set of tools to diagnose and fix these bottlenecks. This guide walks you through the most impactful React performance optimization techniques, with real-world examples you can apply immediately.

Measure Before You Optimize

Never optimize blindly. Use the React DevTools Profiler and Chrome's Performance panel to identify actual bottlenecks first. Premature optimization wastes time and introduces unnecessary complexity. Profile, identify the slowest components, then fix them.

Understanding React's Rendering Behavior

Before diving into optimizations, you must understand why React re-renders components. React re-renders a component whenever:

Re-rendering isn't always expensive — React's virtual DOM diffing is fast. The problem arises when re-renders trigger expensive computations, cause child subtrees to re-render unnecessarily, or happen at very high frequency (e.g., on every keystroke or mouse move).

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 is the practice of caching a result so it doesn't need to be recalculated. React offers three memoization primitives — each with a distinct purpose.

React.memo — Prevent Unnecessary Component Re-renders

React.memo is a higher-order component that wraps a functional component and skips re-rendering if its props haven't changed (shallow comparison). It's useful when a child component receives the same props on every parent re-render.

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

// With memo — 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 uses shallow comparison. If you pass an object literal style={{ color: 'red' }} or inline function onClick={() => doSomething()} as a prop, a new reference is created on every render, defeating memo. Always use useMemo or useCallback to stabilize those references.

useCallback — Stabilize Function References

useCallback returns a memoized version of a callback function that only changes when one of its dependencies changes. Without it, a new function reference is created on every render, causing React.memo children to re-render 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

useMemo memoizes the result of a computation. Use it when a derived value is expensive to calculate and its dependencies change infrequently.

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

Shipping one giant JavaScript bundle is one of the most common performance killers. Code splitting lets you split your app into smaller chunks that are loaded on demand, dramatically reducing your initial page load time.

React.lazy and Suspense

React's built-in React.lazy enables component-level code splitting. When the lazy component is first rendered, React downloads its bundle asynchronously. Suspense provides a loading fallback in the meantime.

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>
    );
}

Dynamic Imports for Non-Component Code

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

Rendering thousands of DOM nodes is expensive regardless of how fast your JavaScript is. If your list has more than ~100 items visible at once, virtualization is the single biggest performance win available. The idea: only render items currently in the viewport.

1

Install a virtualization library

The most popular options are react-window (lightweight) and react-virtual (TanStack Virtual — headless, flexible). For most use cases, react-window is the easiest starting point.

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 — massive memory and paint savings

State Management Performance

Poor state placement is the number one cause of unnecessary re-renders in real-world React apps. The goal is to keep state as local as possible and avoid triggering large subtree re-renders.

Collocate State — Don't Lift Unnecessarily

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 Optimization — Split Contexts by Update Frequency

A common mistake is putting both frequently-changing values (like current user's notifications) and rarely-changing values (like theme) in a single context. Every context update re-renders all consumers.

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>
    );
}

useReducer for Complex State Transitions

When state transitions are complex or interdependent, useReducer can be both cleaner and faster than multiple useState calls. The reducer is pure, making it easy to test, and React batches the dispatch calls.

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>;
}

Profiling and Measuring Performance

React DevTools ships with a built-in Profiler that lets you record rendering sessions and identify which components are taking the most time. Here's how to use it effectively:

1

Install React DevTools

Install the React Developer Tools browser extension for Chrome or Firefox. Open DevTools, navigate to the "Profiler" tab.

2

Record a session

Click the record button, interact with the slow part of your app, then stop recording. The flame chart shows each component and how long it took to render.

3

Identify re-render causes

Click on a component in the flame chart. The panel shows "Why did this render?" — listing which props, state, or hooks changed. This tells you exactly what to memoize.

4

Use the Profiler API in code

Wrap components in React's <Profiler> component to log render timings programmatically — useful for production performance monitoring.

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

JavaScript isn't the only bottleneck. Images are often the heaviest assets on a page. A few targeted optimizations can make a dramatic difference:

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 — load eagerly
            quality={85}
        />
    );
}

Summary and Key Takeaways

React performance optimization is about eliminating wasted work at every layer of the stack. Here's a prioritized checklist:

Performance Optimization Checklist

  1. Profile first — use React DevTools Profiler to find actual bottlenecks before optimizing
  2. Colocate state — keep state as close to where it's used as possible to limit re-render scope
  3. Memoize selectively — use React.memo, useCallback, and useMemo where profiling shows unnecessary re-renders
  4. Code split by route — use React.lazy + Suspense so users only download code they need
  5. Virtualize long lists — never render more DOM nodes than are visible; use react-window or TanStack Virtual
  6. Split contexts — separate high-frequency from low-frequency context values to avoid broad re-renders
  7. Optimize images — use WebP/AVIF, lazy loading, and correct sizing
  8. Measure in production — use the Profiler API and Real User Monitoring (RUM) for real-world data
The Golden Rule

Don't add useMemo, useCallback, or React.memo everywhere "just in case." Each one adds overhead (memory for the cache, time for the comparison). Only apply them where profiling shows a real problem. Premature optimization is the root of all evil — even in React.

"The fastest code is the code that doesn't run at all. In React, the fastest render is the render that never happens."
— A core principle of React optimization

By understanding React's rendering model and applying these techniques deliberately, you can build applications that feel instant even as they grow in complexity. Start with profiling, fix the biggest bottleneck, measure again, and repeat. Performance is a feature — and it's one your users will notice.

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.