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.
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.
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
| Hook | What it caches | Use when |
|---|---|---|
useMemo | The return value of a function | Expensive calculations |
useCallback | The function itself | Passing 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.
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:
- Profile first — use React DevTools Profiler to identify actual slow components
- Check your state structure — unnecessary re-renders often come from state that's too high in the tree
- Memoize expensive operations — large list filtering, sorting, complex calculations
- Memoize stable callbacks passed to memoized children
- 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
| Tool | Purpose |
|---|---|
React.memo | Prevent child re-renders when props haven't changed |
useMemo | Cache the result of an expensive calculation |
useCallback | Cache a function reference to keep it stable |
React.lazy + Suspense | Load 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.