Rohan Yeole - Homepage Rohan Yeole

React Performance Optimization in 2026: useMemo, useCallback, and Code Splitting

Mar 1, 20261 min read

Most React performance problems come from unnecessary re-renders, not slow JavaScript. A component that renders 10 times when it should render once costs more than optimizing the render itself. The solution is finding where the unnecessary renders come from — not adding useMemo everywhere.

Step 1: Find the Problem with React DevTools Profiler

Before any optimization, measure. Open React DevTools → Profiler tab → Record → interact with the slow part of your app → Stop.

The Profiler shows: - Which components rendered - How long each render took - Why each component rendered (props changed, state changed, parent rendered)

A component marked "Rendered because parent rendered" with no changed props is a candidate for React.memo.

When useMemo Actually Helps

useMemo caches the result of a computation:

// Without useMemo — recalculated on every render
const expensiveResult = computeExpensiveValue(data);

// With useMemo — recalculated only when data changes
const expensiveResult = useMemo(() => computeExpensiveValue(data), [data]);

useMemo is worth using when: 1. The computation is genuinely expensive (sorting/filtering thousands of items) 2. The result is used as a dependency in another useMemo or useEffect 3. The result is passed as a prop to a memoized child component

useMemo is not worth using for: - Simple arithmetic or string operations - Object creation that is not used as a prop or dependency - Everything "just in case" — it adds overhead itself

// Not worth it — the computation is trivial
const fullName = useMemo(() => `${firstName} ${lastName}`, [firstName, lastName]);
// Just do:
const fullName = `${firstName} ${lastName}`;

When useCallback Actually Helps

useCallback caches a function reference:

// Without useCallback — new function reference on every render
const handleClick = () => doSomething(id);

// With useCallback — same reference unless id changes
const handleClick = useCallback(() => doSomething(id), [id]);

useCallback matters only when the function is passed as a prop to a memoized child, or used as a useEffect dependency. A new function reference every render causes the child to re-render (defeating React.memo) or the effect to re-run.

// Useful: handleSubmit is passed to a memoized form component
const handleSubmit = useCallback(async (data) => {
    await saveData(data);
    navigate("/success");
}, [navigate]);

return <MemoizedForm onSubmit={handleSubmit} />;

React.memo: Prevent Child Re-renders

React.memo prevents a component from re-rendering when its props have not changed:

const UserCard = React.memo(({ user, onSelect }) => {
    return (
        <div onClick={() => onSelect(user.id)}>
            {user.name}
        </div>
    );
});

For React.memo to work, the props must be stable references. If onSelect is a new function every render, React.memo does nothing — pair it with useCallback:

// In the parent:
const handleSelect = useCallback((id) => selectUser(id), []);
return <UserCard user={user} onSelect={handleSelect} />;

Code Splitting with React.lazy

Large JavaScript bundles block initial page load. Split your bundle by route so users only download what they need:

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

const Dashboard = lazy(() => import("./pages/Dashboard"));
const Settings = lazy(() => import("./pages/Settings"));
const Reports = lazy(() => import("./pages/Reports"));

function App() {
    return (
        <Suspense fallback={<LoadingSpinner />}>
            <Routes>
                <Route path="/dashboard" element={<Dashboard />} />
                <Route path="/settings" element={<Settings />} />
                <Route path="/reports" element={<Reports />} />
            </Routes>
        </Suspense>
    );
}

Each lazy import becomes a separate chunk. The browser downloads only the current route's code — the Dashboard chunk is not downloaded until the user navigates to /dashboard.

Virtual Lists for Large Datasets

Rendering 1,000 list items is slow — the DOM has 1,000 nodes. Virtual lists render only the visible items:

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

function LargeList({ items }) {
    const parentRef = useRef(null);

    const virtualizer = useVirtualizer({
        count: items.length,
        getScrollElement: () => parentRef.current,
        estimateSize: () => 60,  // Estimated row height in pixels
    });

    return (
        <div ref={parentRef} style={{ height: "600px", overflow: "auto" }}>
            <div style={{ height: `${virtualizer.getTotalSize()}px`, position: "relative" }}>
                {virtualizer.getVirtualItems().map((virtualRow) => (
                    <div
                        key={virtualRow.index}
                        style={{
                            position: "absolute",
                            top: `${virtualRow.start}px`,
                            width: "100%",
                        }}
                    >
                        {items[virtualRow.index].name}
                    </div>
                ))}
            </div>
        </div>
    );
}

Instead of 1,000 DOM nodes, only 10–15 visible items are rendered at any time. The performance improvement for lists over ~200 items is dramatic.

State Colocation: The Most Underrated Optimization

Moving state down reduces how many components re-render on state changes:

// BAD: search query in parent causes whole list to re-render
function App() {
    const [search, setSearch] = useState("");
    return (
        <>
            <SearchInput value={search} onChange={setSearch} />
            <HeavyDashboard />  {/* Re-renders every keystroke */}
            <UserList search={search} />
        </>
    );
}

// GOOD: search state in a component that only needs it
function SearchSection() {
    const [search, setSearch] = useState("");
    return (
        <>
            <SearchInput value={search} onChange={setSearch} />
            <UserList search={search} />
        </>
    );
}

function App() {
    return (
        <>
            <SearchSection />
            <HeavyDashboard />  {/* No longer re-renders on search */}
        </>
    );
}

Colocating state is often more effective than useMemo or React.memo — and requires no special APIs.

The Premature Optimization Trap

Wrapping every function in useCallback and every value in useMemo does not make your app faster — it makes it more complex. useMemo and useCallback have a cost: creating and caching the value, running the comparison on every render. For cheap operations, the overhead exceeds the savings.

The correct order: 1. Build the feature correctly first 2. Measure with the Profiler if you notice slowness 3. Fix the actual bottleneck (usually state location or unnecessary re-renders) 4. Apply useMemo/useCallback/React.memo only where the Profiler confirms it helps

If you need a performant React application — properly structured, optimized where it matters, and tested — hire me as a React developer or as a frontend developer.

Chat with me on WhatsApp