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. Hands-on Exercise
Prove the memoization actually works, then find where it shouldn't
Build a scenario where the performance problem is real and visible, fix it, and confirm the fix with your own eyes.
Requirements:
- 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.
7. 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.