Measure Before You Optimise
React is fast by default, and the great majority of applications never need anything in this lesson. Re-rendering a component is usually a fraction of a millisecond — running a function and comparing some objects. Adding memo, useMemo and useCallback everywhere makes code harder to read, adds its own small cost, and in most codebases improves nothing measurable.
So the first tool is not a hook, it is the Profiler in the React Developer Tools browser extension. Record an interaction that feels slow, and it shows you which components rendered, how many times, and how long each took. That turns a vague suspicion into a specific component with a number attached — and very often the culprit is not what you expected.
The second thing to establish is whether you have a React problem at all. A page that takes four seconds to appear because it downloads a three-megabyte image, or because the API is slow, will not be helped by any of this. Check the Network tab before the Profiler.
When something genuinely is slow in React, the cause is almost always one of the four below. Work out which one it is before choosing a fix.
- Too much work in one render — an expensive calculation running on every keystroke
- Too many components re-rendering — a value changing near the top of a large tree
- Too many elements on screen at once — a list of several thousand rows
- Too much JavaScript downloaded before anything appears — a bundle problem, not a render problem
- Profile a production build when you can. Development builds carry extra checks and warnings that make everything slower, and StrictMode renders components twice — so numbers from
npm run devare pessimistic and can point you at the wrong thing.
What Actually Causes a Re-render
A misconception worth clearing up first, because it leads people to optimise the wrong thing. A component does not re-render because its props changed. It re-renders because its parent re-rendered, or because its own state changed, or because a context it consumes changed. When a parent renders, React re-renders all of its children by default — whether their props changed or not.
That sounds wasteful and usually is not, because rendering is cheap. It only matters when the children are expensive, when there are hundreds of them, or when the parent re-renders very frequently — a form that updates state on every keystroke, for instance.
Which leads to the most under-used optimisation in React, and the one to try first: move the state down. If only one small part of a big page uses a piece of state, put that state in a small component of its own. A search box that re-renders itself on every keystroke costs nothing. A search box that re-renders a page containing a two-hundred-row table costs a lot, and the fix is a structural change rather than a hook.
A second structural trick: content passed as children is created by the outer component, not by the one receiving it. So a component that re-renders often does not re-render the children handed to it from above — they are the same elements as before. Restructuring to take advantage of that often removes the need for memo entirely.
// SLOW: every keystroke re-renders the entire page, including the big table
function Page({ rows }) {
const [query, setQuery] = useState('');
return (
<div>
<input value={query} onChange={e => setQuery(e.target.value)} />
<HugeTable rows={rows} /> {/* re-renders on every keystroke */}
</div>
);
}
// FAST: the state now lives in the only component that needs it
function SearchBox() {
const [query, setQuery] = useState('');
return <input value={query} onChange={e => setQuery(e.target.value)} />;
}
function Page({ rows }) {
return (
<div>
<SearchBox /> {/* re-renders alone */}
<HugeTable rows={rows} />
</div>
);
}
// Same idea using children: Layout re-rendering does not re-render <HugeTable />,
// because that element was created by App, not by Layout.
function App({ rows }) {
return (
<Layout>
<HugeTable rows={rows} />
</Layout>
);
} - If the state must stay where it is because several parts of the page need it, that is when the memoisation tools below become relevant. Try the structural fix first — it is faster, simpler, and cannot go stale.
React.memo and Why It Often Does Nothing
memo wraps a component and tells React: if this component's props are the same as last time, skip re-rendering it. The comparison is shallow — React checks each prop with the same identity test === uses, one level deep.
That word shallow is the whole story, and the reason so much memo in real codebases achieves nothing. If the parent passes an object literal, an array literal or an arrow function inline, a brand-new value is created on every render of the parent. The contents may be identical; the identity is not. memo compares, finds a difference, and re-renders exactly as it would have without the wrapper — while you pay for the comparison as well.
So memo only helps when every prop is either a primitive or a value whose identity is preserved between renders. That is where useMemo and useCallback come in: not as speed-ups in themselves, but as a way to keep identities stable so that memo can do its job. The three are a set, and using one without the others is usually pointless.
The corollary: do not wrap components in memo by default. It is worth it for a component that renders often with unchanged props and does real work — a row in a long table, a heavy chart. For a component that renders a heading, it is noise.
import { memo, useCallback, useState } from 'react';
const OrderRow = memo(function OrderRow({ order, onCancel }) {
console.log('rendering row', order.id);
return <tr><td>{order.id}</td><td><button onClick={() => onCancel(order.id)}>Cancel</button></td></tr>;
});
// memo achieves NOTHING here: both style and onCancel are new every render
function OrdersBroken({ orders }) {
const [tick, setTick] = useState(0);
return orders.map(o => (
<OrderRow key={o.id} order={o} style={{ padding: 8 }} onCancel={id => cancel(id)} />
));
}
// memo works here: onCancel keeps its identity, and style is a constant
const ROW_STYLE = { padding: 8 };
function Orders({ orders }) {
const [tick, setTick] = useState(0);
const handleCancel = useCallback((id) => {
cancelOrder(id);
}, []);
return orders.map(o => (
<OrderRow key={o.id} order={o} style={ROW_STYLE} onCancel={handleCancel} />
));
} - The
orderprop still has to keep its identity for this to work — which it will, as long as you update your list immutably withmapand only replace the objects that actually changed. Rebuilding every object on every update defeatsmemocompletely.
useMemo and useCallback
useMemo remembers the result of a calculation between renders and only recalculates when one of its dependencies changes. useCallback does the same for a function definition, remembering the function itself. They take the same dependency array as useEffect, and it works the same way.
There are exactly two good reasons to use them. The first is a genuinely expensive calculation — sorting or grouping tens of thousands of rows, or something similarly heavy — that would otherwise run on every render. The second, and in practice more common, is keeping the identity of an object, array or function stable so that a memo-wrapped child or an effect's dependency array does not see a change that did not happen. That second use is about correctness as much as speed; it is the fix for the infinite-loop-from-an-object-dependency problem in the effects lesson.
They are not free. Every one adds a dependency array to keep correct, code to read, and a small amount of work on each render to compare dependencies. Filtering a list of two hundred items takes microseconds — wrapping it in useMemo makes the code longer and the app no faster. Use them where you have a reason you can state out loud.
One accuracy point worth knowing: these hooks are a performance optimisation, not a guarantee. React reserves the right to discard a cached value. Never write code whose correctness depends on useMemo having kept something — for example, never do side effects inside the memo function.
import { useMemo, useCallback } from 'react';
function Report({ transactions, month }) {
// Worth memoising: real work over a large array
const summary = useMemo(() => {
return transactions
.filter(t => t.month === month)
.reduce((acc, t) => {
acc[t.category] = (acc[t.category] ?? 0) + t.amount;
return acc;
}, {});
}, [transactions, month]);
// Worth memoising: keeps identity stable for a memo-wrapped child
const handleExport = useCallback(() => {
downloadCsv(summary);
}, [summary]);
return <ReportTable summary={summary} onExport={handleExport} />;
}
// NOT worth memoising — this is already instant
const fullName = useMemo(() => `${first} ${last}`, [first, last]); // just write it
const visible = useMemo(() => items.filter(i => i.active), [items]); // for 50 items, no useMemo(fn, deps)— remembers a computed valueuseCallback(fn, deps)— remembers a function- Use for calculations that are actually expensive, measured not assumed
- Use to keep identities stable for
memochildren and effect dependencies - Do not use for cheap arithmetic, string joins or small filters
- Never rely on the cache for correctness — no side effects inside
useCallback(fn, deps)is equivalent touseMemo(() => fn, deps). It exists only because wrapping functions is common enough to deserve its own name.
Loading Less JavaScript
A different kind of slowness has nothing to do with rendering: the user waits because the browser is still downloading your bundle. By default every component in your app ends up in one JavaScript file, so a visitor to the home page also downloads the admin dashboard, the chart library and the rich text editor they will never open.
React.lazy together with <Suspense> splits that up. A lazily imported component becomes its own file, fetched only when it is first rendered, and Suspense supplies something to show while that download happens. Route components are the natural place to apply it: each page becomes a separate chunk, and the first load only includes the page the user actually asked for.
This matters more than it might seem for an Indian audience. A large bundle is barely noticeable on campus Wi-Fi and painful on a patchy mobile connection, which is how most of your users will arrive. Splitting by route is usually a few lines of change for a substantial improvement in how quickly the first screen appears.
Two related wins are not React at all but belong in the same conversation: compress and correctly size your images, which are almost always the largest thing on a page, and check whether a heavy library is worth its size before adding it.
import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';
// Loaded immediately — the first page users see
import Home from './pages/Home';
// Loaded only when the route is visited
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Reports = lazy(() => import('./pages/Reports'));
function App() {
return (
<Suspense fallback={<p>Loading…</p>}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/reports" element={<Reports />} />
</Routes>
</Suspense>
);
}
// After npm run build, Vite prints the size of each chunk.
// That output is the quickest way to see what is making your bundle large. - A list with thousands of rows on screen is a rendering problem no amount of memoisation fixes, because the cost is in the DOM nodes themselves. The answer there is windowing — rendering only the rows currently visible — using a library such as
react-window. Paginating the data is usually simpler and just as effective.
A Sensible Order of Attack
Put together, the advice in this lesson is a sequence rather than a menu. Confirm there is a real problem, find where it is, then apply the cheapest fix that addresses it. Reaching straight for memo is the common mistake, and it is near the bottom of the list for good reason.
It is also worth saying plainly what good React performance usually looks like in practice: sensible component boundaries, state kept close to where it is used, lists that are paginated rather than infinite, images that are the right size, and no memoisation at all. Most fast React apps are fast because of their structure, not because of their hooks.
- Confirm the problem is real — the Profiler and the Network tab, not a hunch
- Fix data problems first — a slow API, oversized images, an enormous list
- Move state down so fewer components re-render
- Restructure with
childrenso expensive subtrees are not recreated - Split routes with
lazyandSuspenseto shrink the first download - Then, and only then,
memoplususeMemoanduseCallbacktogether - For thousands of rows, paginate — or window the list with a dedicated library
- Optimisations have a maintenance cost that never goes away. Every
useMemois a dependency array someone has to keep correct. Adding one to a component that was never slow is not a neutral choice — it is a small permanent tax with no benefit.
