Performance: Render Cost, Re-renders, Memoization & Bundle Size
Explain what makes a React render expensive, control when components re-render using memoization primitives, and shrink what ships to the browser with code splitting, lazy loading, and tree shaking.
React performance work splits into two very different problems: making each render cheaper (or avoiding unnecessary renders entirely), and making the JavaScript the browser has to download and parse smaller. Beginners often reach for useMemo everywhere without understanding what a render actually costs, or they ship a single giant bundle without realizing most users never visit most routes. This lesson builds the mental model for both: how React's render phase works, how re-renders propagate through a tree, how memo, useMemo, and useCallback actually prevent work (and how they can make things worse), and how the bundler-level tools — code splitting, lazy loading, tree shaking — keep the initial payload small. Every technique here is a trade-off, not a free win, and the lesson is explicit about when each one helps versus when it just adds complexity.
What a render actually costs
A React 'render' is the call to your component function (or the evaluation of your function component body) that produces a new React element tree — it is NOT necessarily a DOM update. Render cost has two components: the JavaScript execution cost of running your component function and every child component function beneath it, and the reconciliation cost of diffing the new element tree against the previous one (the 'Virtual DOM diff'). Only if reconciliation finds actual differences does React commit changes to the real DOM, which triggers browser layout and paint — by far the most expensive part of the whole pipeline.
The critical misconception to unlearn: 're-render' does not mean 're-paint'. A parent re-rendering because its state changed will, by default, re-render every child component function too (this is React's default behavior — it re-renders the whole subtree unless you tell it not to). If those children return the same JSX output as before, reconciliation still has to walk and diff their new element trees against the old ones, which costs CPU time even though nothing changes on screen. For a small tree this is invisible. For a tree with hundreds of components, or components doing expensive work in their body (heavy computation, large list mapping, chart rendering), it is measurable jank.
This means the first performance question is never 'should I memoize this?' — it is 'does this component tree actually re-render more than it needs to, and does that re-render do expensive work?' Profile before optimizing (see the React Profiling module) — most components are cheap enough that re-rendering them is irrelevant, and premature memoization adds complexity and its own overhead for no measurable benefit.
Why components re-render
A function component re-renders for exactly four reasons: its own state changed (useState/useReducer setter called with a new value), its parent re-rendered and did not skip it (the default), a context value it subscribes to changed, or (rare) a parent forced a re-mount by changing the key. Understanding this list matters because most 'why is this re-rendering' mysteries trace back to reason two: an ancestor re-rendered, and by default every function component in that subtree re-renders whether or not its own props changed.
This cascading default is intentional — it keeps React's mental model simple ('render is just calling functions, always in sync with state') and avoids a whole class of stale-UI bugs that plagued manual DOM diffing before React. The cost is that a single keystroke updating state at the top of a large tree re-runs every component function below it unless you explicitly opt components out with memoization.
Context is a special re-render trigger worth calling out on its own: EVERY component that calls useContext(SomeContext) re-renders whenever that context's value changes, regardless of whether the component is wrapped in memo — memo only blocks re-renders caused by a parent re-rendering with unchanged props, it does nothing for context subscriptions. A context value that changes often (e.g. an object recreated every render) will re-render every consumer on every render of the provider, which is a common performance trap in apps that put frequently-changing state (like mouse position or form field values) into a broad context.
• Own state/reducer update -> re-renders • Parent re-rendered -> child re-renders by default (even with identical props) • Subscribed context value changed (by Object.is) -> re-renders, memo does not help • Key changed -> unmount + fresh mount, not a re-render at all
memo, useMemo, and useCallback — what each one actually does
React.memo(Component) wraps a component so React skips re-rendering it when its parent re-renders, PROVIDED every prop is shallow-equal (Object.is) to the previous render's props. It does nothing on the component's own state changes or context subscriptions — those still trigger a render normally. memo is a targeted tool for components that (a) render relatively often due to parent re-renders, (b) are themselves expensive to render, and (c) usually receive the same props. If props change on every render anyway, memo's shallow comparison is pure overhead with zero benefit.
useMemo(fn, deps) caches the RESULT of a computation across renders, recomputing only when a dependency changes. It is for expensive derived values — filtering a large array, computing a chart layout, formatting large datasets — not for avoiding component re-renders directly. A common mistake is wrapping cheap computations (useMemo(() => a + b, [a, b])) in useMemo: the memoization bookkeeping (storing deps, comparing them, retrieving the cache) can cost more than just recomputing a + b every time.
useCallback(fn, deps) caches a FUNCTION REFERENCE across renders, recreating it only when a dependency changes. Its primary use case is not 'performance' in isolation — it is making memo work correctly. If you pass an inline function as a prop to a memo-wrapped child, that function is a new reference every render, which breaks the child's shallow-prop-equality check and defeats the memo entirely. Wrapping the function in useCallback with correct dependencies gives it a stable identity so memo can actually skip re-renders. Used alone, without a memo-wrapped consumer downstream (or as a useEffect dependency), useCallback provides no benefit and adds overhead.
React 19 introduces the React Compiler (an opt-in, build-time tool, still described as experimental/rolling out at the time of this course) which analyzes your component code and automatically inserts memoization equivalent to hand-written memo/useMemo/useCallback — the goal being that manual memoization becomes largely unnecessary in codebases that adopt it. Because adoption is still partial across the ecosystem, understanding manual memoization remains essential; treat the compiler as a future automation of this lesson's techniques, not a replacement for understanding them.
Code splitting, lazy loading, and bundle size
Everything above concerns the RUNTIME cost of rendering. A completely separate performance dimension is the DOWNLOAD and PARSE cost of your JavaScript bundle before any component can render at all. Bundlers (Vite, webpack, Rspack) by default can produce one large JavaScript file containing your entire application — every route, every rarely-used modal, every admin-only feature — which every visitor downloads and parses even if they only ever see the login page.
Code splitting breaks that single bundle into multiple chunks that load on demand. In React, the standard mechanism is React.lazy() combined with dynamic import(): const Settings = lazy(() => import('./Settings')) tells the bundler to put Settings and its dependencies into a separate chunk file that is only fetched when that code path is actually reached (e.g. when the user navigates to /settings). Wrapping the lazy component in a <Suspense fallback={...}> boundary gives React something to render while that chunk downloads. Route-based code splitting (one chunk per page/route) is the highest-value, lowest-effort place to start — it directly shrinks the bundle every user must download just to see the app's first screen.
Tree shaking is a build-time (not runtime) optimization: bundlers analyze static import/export statements and remove code that is never actually referenced, so importing one function from a large utility library does not necessarily ship the whole library — PROVIDED the library is written with ES modules and side-effect-free exports (check for a "sideEffects": false field or explicit list in the library's package.json). Tree shaking breaks silently if you use CommonJS (require) imports, import an entire library as a namespace (import * as _ from 'lodash'), or the library itself has side effects at the module level that the bundler can't prove are safe to drop.
Bundle size should be measured, not guessed — tools like source-map-explorer, webpack-bundle-analyzer, or Vite's rollup-plugin-visualizer produce a visual breakdown of what is actually in your production bundle, which routinely reveals surprises (an entire icon library imported for three icons, a moment.js locale bundle, a duplicated dependency pulled in at two different versions).
• Route-based splitting: highest ROI, split at the page/router level first • Component-based splitting: lazy-load heavy, rarely-used widgets (rich text editors, charting libraries, modals) • Tree shaking requires ESM + side-effect-free exports — verify, don't assume • Always measure with a bundle analyzer before and after — intuition about bundle contents is frequently wrong
The three memoization primitives
useCallback only pays off when the function is passed to a memo-wrapped child or used as an effect dependency. A custom memo comparator that misses a changed prop causes silent stale UI — prefer default shallow comparison unless you have measured a real need.
// 1. React.memo — skip re-render if props are shallow-equal
const Row = React.memo(function Row({ item }: { item: Item }) {
return <li>{item.label}</li>;
});
// 2. useMemo — cache an expensive computed VALUE
const sorted = useMemo(
() => items.slice().sort((a, b) => a.rank - b.rank),
[items]
);
// 3. useCallback — cache a stable FUNCTION reference
const handleSelect = useCallback(
(id: string) => setSelectedId(id),
[] // no deps: setSelectedId from useState is always stable
);
// Custom comparison for memo (use sparingly — easy to get wrong)
const Row2 = React.memo(RowImpl, (prev, next) => prev.item.id === next.item.id);Code Examples
Every keystroke updates Dashboard's state, so Dashboard re-renders, and by React's default, ExpensiveChart re-renders with it — even though staticChartData never changes. For a chart doing real layout work, this is wasted CPU on every keystroke.
<ExpensiveChart data={staticChartData} /> Common mistake: Assuming React 'knows' the data prop didn't change. It doesn't skip re-rendering by default — you have to opt in.
Wrapping ExpensiveChart in React.memo makes React compare its props (data) by reference before re-rendering. Since staticChartData's reference never changes across Dashboard's re-renders, ExpensiveChart is skipped entirely — React reuses its last rendered output. This only works because staticChartData is a stable reference (defined outside the component or itself memoized). If Dashboard computed data inline as a new array each render, memo would see a new reference every time and re-render anyway.
const ExpensiveChart = React.memo(function ExpensiveChart(...
TodoRow is memo-wrapped so it should skip re-rendering when its todo prop is unchanged. But onToggle is also a prop — if TodoList passed an inline arrow function, it would be a brand-new reference every render, breaking TodoRow's shallow prop comparison for every row on every render. useCallback with an empty dependency array gives handleToggle a stable identity, so unrelated rows genuinely skip re-rendering when TodoList re-renders for unrelated reasons.
const handleToggle = useCallback((id: string) => { ... }, []);
A user landing on '/' downloads only the Dashboard chunk (plus shared app shell code) — Settings and AdminReports, and everything they import, stay unfetched until the user actually navigates there. This is the single highest-leverage bundle-size optimization for most multi-page apps. Expected result: Initial JS payload shrinks to roughly the size of the landing route instead of the whole app. AdminReports is a great lazy-split candidate specifically because most users (non-admins) never load it at all — code splitting turns 'shipped to everyone' into 'shipped only to whoever needs it'.
function Dashboard() {
const [query, setQuery] = useState("");
// ExpensiveChart re-renders on every keystroke, even though
// it receives the same `data` prop every time.
return (
<div>
<input value={query} onChange={e => setQuery(e.target.value)} />
<ExpensiveChart data={staticChartData} />
</div>
);
}
function ExpensiveChart({ data }: { data: ChartPoint[] }) {
console.log("ExpensiveChart rendered"); // fires on every keystroke
return <svg>{/* expensive layout computation */}</svg>;
}const ExpensiveChart = React.memo(function ExpensiveChart({
data,
}: {
data: ChartPoint[];
}) {
console.log("ExpensiveChart rendered"); // now only fires when data changes
return <svg>{/* expensive layout computation */}</svg>;
});function TodoList({ todos }: { todos: Todo[] }) {
const [, forceTick] = useState(0);
// WITHOUT useCallback, this is a new function every render,
// so TodoRow's memo never skips anything.
const handleToggle = useCallback((id: string) => {
setTodosDone(prev => prev.map(t => (t.id === id ? { ...t, done: !t.done } : t)));
}, []);
return (
<ul>
{todos.map(t => (
<TodoRow key={t.id} todo={t} onToggle={handleToggle} />
))}
</ul>
);
}
const TodoRow = React.memo(function TodoRow({
todo,
onToggle,
}: {
todo: Todo;
onToggle: (id: string) => void;
}) {
return (
<li onClick={() => onToggle(todo.id)}>
{todo.done ? "✓ " : ""}
{todo.text}
</li>
);
});import { lazy, Suspense } from "react";
import { Routes, Route } from "react-router-dom";
// Each import() becomes its own chunk, fetched only when visited.
const Dashboard = lazy(() => import("./pages/Dashboard"));
const Settings = lazy(() => import("./pages/Settings"));
const AdminReports = lazy(() => import("./pages/AdminReports"));
function App() {
return (
<Suspense fallback={<PageSkeleton />}>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
<Route path="/admin/reports" element={<AdminReports />} />
</Routes>
</Suspense>
);
}Render cascade vs. a memo boundary
memo() inserts a props-equality check that can stop the render cascade from propagating into an expensive subtree.
Without memo: With memo on ExpensiveChild:
<Parent> state changes <Parent> state changes
| |
v (re-renders) v (re-renders)
<Parent/> <Parent/>
| \ | \
v v v v
<ChildA/> <ExpensiveChild/> <ChildA/> <ExpensiveChild/>
re-runs re-runs (wasted!) re-runs [props same -> SKIPPED]
output reused as-isImportant Rules
Profile first: don't add memo/useMemo/useCallback without measured evidence a render is both frequent and expensive (see the Profiling lesson).
memo only blocks parent-triggered re-renders with unchanged props — it does not affect the component's own state updates or context subscriptions.
useCallback is worthless on its own; it only pays off paired with a memo-wrapped consumer or as a stable effect dependency.
useMemo is for expensive VALUE computation, not a general performance hammer — cheap computations don't need it.
Split routes/heavy widgets with React.lazy + Suspense; measure bundle impact with an analyzer, don't guess.
Verify tree shaking is actually happening for your dependencies — CommonJS imports and namespace imports (import * as x) silently defeat it.
Why this matters at scale
On a data-heavy admin dashboard with a live-updating sidebar (e.g. notification counts updating every few seconds via websocket), an unmemoized main content area — a large form or table — will silently re-render in full on every sidebar tick, because the sidebar and content share a common re-rendering ancestor. Users report the page 'feels laggy' with no obvious cause; the fix is almost always isolating the frequently-updating state (via a dedicated context, state colocated closer to the sidebar, or a memo boundary around the expensive content) rather than reaching for useMemo inside the content component itself. Separately, teams that skip route-based code splitting commonly ship a single multi-megabyte bundle where 80%+ of the code (admin panels, rarely used settings pages, heavy chart libraries used on one report page) is irrelevant to the median user's first visit — the first fix engineers usually find when they finally run a bundle analyzer.
Wrapping every component in React.memo 'just in case'.
Reach for memo only on components proven (via the Profiler) to render often with unchanged props and to be expensive enough that skipping the render matters.
memo adds a props-comparison cost to every render check. On cheap components, or components whose props actually change every render, that comparison is pure overhead with no payoff — it can make things marginally slower, not faster.
const handleClick = () => doThing(id); <Child onClick={handleClick} /> with Child wrapped in memo, and expecting memo to skip re-renders.
const handleClick = useCallback(() => doThing(id), [id]); — give the function a stable identity so memo's shallow comparison actually holds across renders.
An inline arrow function is a new object reference on every render. React.memo's default shallow comparison sees a 'changed' prop every time and re-renders the child regardless — the memo wrapper becomes dead weight.
import * as _ from 'lodash'; then using only _.debounce.
import debounce from 'lodash/debounce'; or import { debounce } from 'lodash-es';
Namespace imports of a CommonJS-style library defeat tree shaking — the bundler can't prove which parts of the namespace are used, so it ships the entire library. Importing the specific submodule (or using an ESM-native build) lets the bundler include only what's used.
A component wrapped in React.memo still re-renders every time even though its props never change. What is the most likely cause? A. React.memo is deprecated and no longer works B. One of its props is a new object/array/function reference created inline on every parent render C. The component uses useState internally D. The component is too deep in the tree for memo to apply
B. One of its props is a new object/array/function reference created inline on every parent render — memo's default comparison is shallow (Object.is per prop). An inline object, array, or function literal created during the parent's render is a new reference every time, so the comparison always reports 'changed' even if the logical content is identical.
What does useCallback primarily accomplish? A. It caches the return value of an expensive function call B. It prevents the component's own re-renders C. It preserves a stable function reference across renders, useful for memo-wrapped children or effect dependencies D. It automatically debounces the function it wraps
C. It preserves a stable function reference across renders, useful for memo-wrapped children or effect dependencies — useCallback memoizes the function reference itself (not its result), which matters when that reference is passed to a memo-wrapped child (to preserve the memo's benefit) or used in a dependency array (to avoid re-running an effect unnecessarily).
Which statement about code splitting with React.lazy is correct? A. It reduces the runtime cost of rendering a component B. It defers fetching a component's JS chunk until the code path is actually reached, reducing the initial bundle C. It automatically removes unused exports from a module D. It only works with class components
B. It defers fetching a component's JS chunk until the code path is actually reached, reducing the initial bundle — React.lazy paired with dynamic import() tells the bundler to split that component into a separate chunk, fetched on demand — it addresses download/parse cost (bundle size), not runtime render cost. Removing unused exports is tree shaking, a related but distinct optimization.
Given a ProductList component that renders 500 ProductCard children, and a SearchBar sibling that updates its own local state on every keystroke (both inside a shared ProductPage parent), explain concretely which components re-render on each keystroke by default, and describe the minimum set of changes (which components to memo, which props need stabilizing) to make only SearchBar re-render on keystrokes.
If SearchBar's input state is stored in ProductPage's own useState, every keystroke re-renders ProductPage, which by default re-renders both SearchBar and ProductList (and all 500 ProductCards) even though ProductList's props never changed. Two valid fixes: (1) move the search-text state down into SearchBar itself if ProductPage doesn't need the live value (only needs it on submit/debounce), eliminating the shared re-render trigger entirely; or (2) wrap ProductList in React.memo and ensure the products array and any callback props passed to it are stable references (useMemo/useCallback or defined outside the render), so ProductList's memo check finds unchanged props and skips re-rendering all 500 cards even though ProductPage itself re-renders.
Trace where the keystroke's state actually lives. If it lives in ProductPage (the shared parent), every child re-renders by default. Moving the state down, or memoizing ProductList with stable props, are both valid fixes — compare their trade-offs.