Skip to main content

Performance Optimization

Why Performance Matters

React is fast by default — but as your app grows, you may notice components re-rendering more than necessary, causing sluggish UI. React provides a few tools to control when components re-render.

Before optimizing, remember: measure first. Use the React DevTools Profiler to find actual bottlenecks before adding memoization everywhere.

Understanding Re-renders

A component re-renders when:

  • Its state changes
  • Its props change
  • Its parent re-renders

The last point is the key to understanding why optimization is sometimes needed. When a parent re-renders, all its children re-render too — even if their props didn't change.

function Parent() {
const [count, setCount] = useState(0);

return (
<>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
<ExpensiveChild /> {/* re-renders every time count changes */}
</>
);
}

React.memo — Skip Re-renders for Unchanged Props

React.memo wraps a component and tells React: "only re-render this if its props actually changed."

const ExpensiveChild = React.memo(function ExpensiveChild({ data }) {
console.log("ExpensiveChild rendered");
return <div>{data}</div>;
});

Now ExpensiveChild only re-renders when data changes, not every time Parent re-renders.

Shallow comparison

React.memo does a shallow comparison of props. It works well for primitive values (strings, numbers, booleans). For objects and arrays, it compares references — if you create a new object/array on every render, React.memo won't help because the reference is always new.

This is where useMemo and useCallback come in.

useMemo — Cache an Expensive Calculation

useMemo caches the result of a computation and only recomputes it when dependencies change.

const expensiveResult = useMemo(() => {
return computeExpensiveValue(a, b); // only runs when a or b changes
}, [a, b]);

A practical example — filtering a large list:

function ProductList({ products, searchQuery }) {
const filteredProducts = useMemo(
() =>
products.filter((p) =>
p.name.toLowerCase().includes(searchQuery.toLowerCase())
),
[products, searchQuery] // recompute only when these change
);

return (
<ul>
{filteredProducts.map((p) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
);
}

Without useMemo, filter runs on every render — even if products and searchQuery haven't changed. With useMemo, it only reruns when the dependencies change.

Don't overuse useMemo

useMemo has overhead — it stores the result and runs the comparison on every render. For simple operations, it's slower than just recomputing. Only use it when:

  • The computation is genuinely expensive (sorting/filtering large arrays, complex calculations)
  • You've measured with the Profiler and confirmed it's a bottleneck

useCallback — Cache a Function Reference

Every render creates new function instances. This breaks React.memo because the child sees a "new" function prop on every render.

function Parent() {
const [count, setCount] = useState(0);

// New function on every render — breaks React.memo on Child
const handleClick = () => console.log("clicked");

return <Child onClick={handleClick} />;
}

useCallback returns the same function reference across renders (unless dependencies change):

function Parent() {
const [count, setCount] = useState(0);

// Same reference unless dependencies change
const handleClick = useCallback(() => {
console.log("clicked");
}, []); // empty deps = never recreated

return <Child onClick={handleClick} />;
}

const Child = React.memo(function Child({ onClick }) {
console.log("Child rendered");
return <button onClick={onClick}>Click</button>;
});

Now Child only re-renders when handleClick actually changes.

useMemo vs useCallback

HookWhat it cachesUse when
useMemoThe return value of a functionExpensive calculations
useCallbackThe function itselfPassing callbacks to memoized children

They're related: useCallback(fn, deps) is equivalent to useMemo(() => fn, deps).

Code Splitting with React.lazy

Loading your entire app's JavaScript upfront slows down the initial page load. Code splitting lets you load components only when they're needed.

src/App.jsx
import { lazy, Suspense } from "react";
import { Routes, Route } from "react-router-dom";

// These components are loaded only when the route is visited
const Dashboard = lazy(() => import("./pages/Dashboard"));
const Profile = lazy(() => import("./pages/Profile"));
const Settings = lazy(() => import("./pages/Settings"));

function App() {
return (
<Suspense fallback={<p>Loading...</p>}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/profile" element={<Profile />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
);
}

Suspense shows the fallback UI while the lazy component is being loaded. Each route is now a separate JavaScript chunk — users only download the code for pages they actually visit.

Practical Checklist

Before reaching for memoization:

  1. Profile first — use React DevTools Profiler to identify actual slow components
  2. Check your state structure — unnecessary re-renders often come from state that's too high in the tree
  3. Memoize expensive operations — large list filtering, sorting, complex calculations
  4. Memoize stable callbacks passed to memoized children
  5. Lazy load large pages and components that aren't needed on initial render
// Pattern: memoized component with stable callback
const ItemList = React.memo(function ItemList({ items, onSelect }) {
return (
<ul>
{items.map((item) => (
<li key={item.id} onClick={() => onSelect(item.id)}>
{item.name}
</li>
))}
</ul>
);
});

function App() {
const [items] = useState([...]);
const [selected, setSelected] = useState(null);

const handleSelect = useCallback((id) => {
setSelected(id);
}, []); // stable — never recreated

return <ItemList items={items} onSelect={handleSelect} />;
}

Summary

ToolPurpose
React.memoPrevent child re-renders when props haven't changed
useMemoCache the result of an expensive calculation
useCallbackCache a function reference to keep it stable
React.lazy + SuspenseLoad components on demand (code splitting)

The golden rule: don't optimize prematurely. Write clear code first, measure with the Profiler, then optimize where it actually matters.