1. Why Re-renders Are Expensive (Sometimes)
By default, when a component re-renders, every child component in its tree re-renders too — regardless of whether that child's own props actually changed. For a simple component tree this is invisible; React is fast, and diffing a few dozen elements takes microseconds. It becomes a real, felt problem in two specific situations: a genuinely expensive calculation running on every keystroke, or a large list of child components all re-rendering because one unrelated sibling's state changed.
function ProductList({ products, query }: { products: Product[]; query: string }) {
const [isDarkMode, setIsDarkMode] = useState(false);
// Runs on EVERY render, including the one caused by toggling isDarkMode
// -- even though products and query didn't change at all
const filtered = products.filter(p =>
p.name.toLowerCase().includes(query.toLowerCase())
);
return (
<div>
<button onClick={() => setIsDarkMode(!isDarkMode)}>Toggle theme</button>
{filtered.map(p => <ProductCard key={p.id} product={p} />)}
</div>
);
}
Toggling isDarkMode re-renders ProductList, which re-runs
the .filter() call — over the full product list — even though neither
products nor query changed. For a 20-item list, this is
nothing. For a 50,000-item list filtered on every keystroke of an unrelated toggle,
it's a visible stutter. This is exactly the scenario useMemo exists for.
2. useMemo: Memoizing Expensive Computations
useMemo caches the result of a computation between renders,
only recalculating when one of its dependencies actually changes:
import { useMemo, useState } from 'react';
function ProductList({ products, query }: { products: Product[]; query: string }) {
const [isDarkMode, setIsDarkMode] = useState(false);
const filtered = useMemo(() => {
return products.filter(p =>
p.name.toLowerCase().includes(query.toLowerCase())
);
}, [products, query]); // only recompute when these change
return (
<div>
<button onClick={() => setIsDarkMode(!isDarkMode)}>Toggle theme</button>
{filtered.map(p => <ProductCard key={p.id} product={p} />)}
</div>
);
}
Now toggling isDarkMode re-renders the component, but
useMemo sees that products and query are
unchanged (same references, same values) and returns the cached array from
last time instead of re-filtering. The dependency array works exactly like
useEffect's — same reference-equality comparison, same rules.
useMemo vs. useEffect
useMemo runs synchronously during render and returns a value used immediately in the JSX you produce. useEffect runs after the render commits and returns nothing usable in that render — it's for side effects, not values. Mixing these up is a very common early mistake.
3. useCallback: Stable Function References
Every render creates brand-new function instances for anything defined inside the
component body — including event handlers. Usually that's harmless. It becomes a
problem when that function is passed as a prop to a child wrapped in
React.memo (a way to skip a child's re-render when its props are
unchanged): a new function reference every render defeats the memoization entirely,
because "unchanged props" is never true.
const ExpensiveChild = React.memo(function ExpensiveChild({
onSelect,
}: { onSelect: (id: number) => void }) {
console.log('ExpensiveChild rendered'); // logs on every parent render, despite memo
return <button onClick={() => onSelect(1)}>Select</button>;
});
function Parent() {
const [count, setCount] = useState(0);
// A NEW function every render -- breaks ExpensiveChild's memoization
const handleSelect = (id: number) => console.log('selected', id);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
<ExpensiveChild onSelect={handleSelect} />
</div>
);
}
function Parent() {
const [count, setCount] = useState(0);
const handleSelect = useCallback((id: number) => {
console.log('selected', id);
}, []); // same function reference across renders
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
<ExpensiveChild onSelect={handleSelect} />
</div>
);
}
Now clicking the count button re-renders Parent, but
handleSelect is the exact same function reference as last time, so
ExpensiveChild's React.memo check sees unchanged props and
genuinely skips re-rendering it.
useCallback(fn, deps) is useMemo(() => fn, deps)
Literally — useCallback is a thin convenience wrapper around useMemo that memoizes a function instead of a computed value. If that equivalence clicks, you already understand both hooks.
4. useRef: Values That Don't Trigger Renders
useRef(initial) returns a mutable object, {'{ current: initial }'},
that persists across renders — but changing .current does not
trigger a re-render, unlike state. It has two very different everyday uses.
Use 1: Direct DOM access
import { useRef, useEffect } from 'react';
function AutoFocusInput() {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus(); // real DOM node, once mounted
}, []);
return <input ref={inputRef} placeholder="Focused on mount" />;
}
Passing a ref to an element's ref attribute makes React populate
.current with the actual DOM node after it mounts — the escape hatch for
the rare cases you need to call a DOM API directly (.focus(),
measuring .getBoundingClientRect()) that React's declarative model
doesn't otherwise expose.
Use 2: A mutable value that survives renders without causing one
function RenderCounter({ value }: { value: string }) {
const renderCount = useRef(0);
renderCount.current += 1; // mutating .current directly does NOT re-render
return (
<p>
{value} (rendered {renderCount.current} times)
</p>
);
}
This is also exactly how you'd store something like a setInterval ID
(Week 2) or the "ignore" flag from last week's race-condition fix, when you want that
value to persist across renders without it being part of the render output itself.
If a value being wrong on screen for one render would be a bug, it's state. If the value is bookkeeping React itself doesn't need to display, it's a ref.
5. When NOT to Memoize
useMemo and useCallback aren't free — they cost a
comparison on every render and hold onto the cached value in memory. Wrapping every
single function and calculation in memoization "just in case" is a real anti-pattern
that makes code harder to read for a benefit that, for a cheap computation, doesn't
exist.
// Overkill: doubling a number is essentially free.
// The memoization overhead here is comparable to just doing the work.
const doubled = useMemo(() => count * 2, [count]);
// Just:
const doubled = count * 2;
Reach for useMemo when you've identified an actually expensive
computation (filtering/sorting/transforming a large collection, heavy math) or when
the memoized value is itself a dependency of another hook where reference stability
matters. Reach for useCallback specifically when passing a function to a
React.memo-wrapped child, or as a dependency of another
useEffect/useMemo. Outside those cases, a plain calculation
or function is simpler and just as fast.
6. The React Compiler: When You Don't Need to Write This by Hand
Everything in Sections 2–3 is real and worth understanding deeply — but React now
ships an official, stable compiler that statically analyzes your
components and inserts memoization equivalent to what you just wrote by hand,
automatically. Enable it (a Babel or SWC plugin — Next.js turns it on with
reactCompiler: true in next.config.ts, covered in Week 22),
add its companion ESLint plugin, and code like this needs no manual memoization at all:
function ProductList({ products, query }: { products: Product[]; query: string }) {
const [isDarkMode, setIsDarkMode] = useState(false);
// No useMemo. The compiler sees that `filtered` only depends on
// `products` and `query`, and memoizes it for you at build time.
const filtered = products.filter(p =>
p.name.toLowerCase().includes(query.toLowerCase())
);
// No useCallback, no React.memo needed on ProductCard either --
// the compiler memoizes this handler and ProductCard's props automatically.
const handleSelect = (id: number) => console.log('selected', id);
return (
<div>
<button onClick={() => setIsDarkMode(!isDarkMode)}>Toggle theme</button>
{filtered.map(p => <ProductCard key={p.id} product={p} onSelect={handleSelect} />)}
</div>
);
}
The compiled output — not the source you write, the JavaScript that actually ships —
contains the equivalent of useMemo/useCallback calls the
compiler inserted itself, having proven your component follows the Rules of React
(no mutating props or state during render, no conditional hook calls, effects only
read what they declare). If a component breaks those rules, the compiler bails
out silently for that component and leaves it unmemoized — which is exactly
why Sections 1–5 still matter even in a compiler-enabled codebase.
useMemo and useCallback?
In a codebase with the compiler enabled, mostly yes for the routine cases this week covered — let it handle them, and keep your components simpler to read. But you still need the mental model: to recognize when the compiler bailed out (its ESLint plugin flags this), to reason about useRef (the compiler doesn't infer "this should never trigger a render" — that's still an explicit choice you make), and because plenty of production code you'll work in doesn't have the compiler enabled yet.
7. Hands-on Exercise
Prove the memoization actually works, then let the compiler do it instead
Build a scenario where the performance problem is real and visible, fix it by hand, then confirm the compiler produces the same result without the hooks.
Part 1 — Manual memoization:
- Generate an array of 20,000 objects:
{'{ id, name, price }'}with randomized data. - Build a
ProductListlike this week's example: an unrelatedisDarkModetoggle plus aquery-filtered list, rendered as 20,000ProductCardchildren. - Add a
console.loginside the filter function. Click the theme toggle and confirm — before addinguseMemo— that the filter re-runs on every click even thoughquerydidn't change. - Wrap the filter in
useMemowith the correct dependency array, and confirm via the console that toggling the theme no longer re-runs it. - Wrap
ProductCardinReact.memo, pass it anonSelectcallback from the parent, and useuseCallbackto confirm (again via aconsole.loginsideProductCard) that individual cards stop re-rendering when the theme toggles.
If cards still re-render after adding useCallback, double-check you're passing the filtered array from useMemo down, not re-deriving a new array somewhere else in the render — a fresh array reference breaks React.memo on the list just as easily as a fresh function does.
Part 2 — Let the compiler do it:
- In a copy of the project, install
babel-plugin-react-compilerandeslint-plugin-react-compiler, and wire the Babel plugin into your Vite config. - Strip out every
useMemo,useCallback, andReact.memoyou added in Part 1 — back to the plain, unmemoized version from Section 1. - Rebuild and re-run your console.log checks from Part 1, step 3 — confirm the filter no longer re-runs on an unrelated theme toggle, and
ProductCardno longer re-renders either, purely from the compiler, with zero hooks in your source. - Deliberately break a Rule of React in one component — e.g. mutate
productsdirectly during render instead of deriving a new array — and confirm via the ESLint plugin that the compiler reports it bailed out for that component.
You won't see the inserted useMemo/useCallback calls in your source — they exist only in the compiled output. To actually observe them, check your bundler's dev output or the "Compiled" badge React DevTools' Components panel shows next to a compiler-optimized component.
8. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
What does useMemo actually cache — the function, or its return value?
What does useMemo actually cache — the function, or its return value?
The return value. useMemo(fn, deps) calls fn once, caches whatever it returns, and hands back that same cached value on every subsequent render until a dependency changes — at which point it calls fn again and caches the new result.
Q2
Why does wrapping a child in React.memo not help if the parent passes it a new inline function every render?
Why does wrapping a child in React.memo not help if the parent passes it a new inline function every render?
React.memo skips a re-render only when every prop is reference-equal to last time. A function literal defined inline in the parent's render body is a brand-new object on every call, so that one prop always "changes," and the memoization never actually triggers — useCallback is what keeps that function reference stable.
Q3
Why doesn't updating a ref's .current property cause a re-render?
Why doesn't updating a ref's .current property cause a re-render?
Refs are intentionally outside React's rendering system — useRef gives you a plain mutable object that React itself never inspects to decide whether to re-render. That's precisely the point: it's a place to keep a value across renders (a DOM node, an interval ID, a flag) without that value being part of what triggers or represents the UI.
Q4
Why is useMemo(() => count * 2, [count]) usually worse than just writing count * 2?
Why is useMemo(() => count * 2, [count]) usually worse than just writing count * 2?
Multiplying a number is essentially free — cheaper, in fact, than the dependency-array comparison useMemo itself performs on every render. Memoization is a tool for computations expensive enough that skipping them is a net win; applied to trivial work, it adds overhead and reading friction for no actual benefit.