Week 3: useMemo, useCallback & useRef

Week 1 established that a render just means "React called your function again." This week is about what to do when that's genuinely too expensive to happen on every render — and about the one hook, useRef, that lets a component hold onto a mutable value that survives renders without ever causing one.

Module 3 of 17 Week 3 of 28 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Memoize an expensive computation with useMemo, correctly
  • Give a child component a stable function reference with useCallback
  • Use useRef for DOM access and render-independent mutable values

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.

ProductList.tsx — the setup for this week
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:

ProductList.tsx — fixed
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.

the problem
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>
  );
}
fixed with useCallback
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

AutoFocusInput.tsx
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

RenderCounter.tsx
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.

The one-line test: state or ref?

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.

unnecessary — don't do this
// 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:

ProductList.tsx — with the compiler enabled, no hooks needed
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.

So should I stop writing 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

Hands-on

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:

  1. Generate an array of 20,000 objects: {'{ id, name, price }'} with randomized data.
  2. Build a ProductList like this week's example: an unrelated isDarkMode toggle plus a query-filtered list, rendered as 20,000 ProductCard children.
  3. Add a console.log inside the filter function. Click the theme toggle and confirm — before adding useMemo — that the filter re-runs on every click even though query didn't change.
  4. Wrap the filter in useMemo with the correct dependency array, and confirm via the console that toggling the theme no longer re-runs it.
  5. Wrap ProductCard in React.memo, pass it an onSelect callback from the parent, and use useCallback to confirm (again via a console.log inside ProductCard) that individual cards stop re-rendering when the theme toggles.
Hint

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:

  1. In a copy of the project, install babel-plugin-react-compiler and eslint-plugin-react-compiler, and wire the Babel plugin into your Vite config.
  2. Strip out every useMemo, useCallback, and React.memo you added in Part 1 — back to the plain, unmemoized version from Section 1.
  3. 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 ProductCard no longer re-renders either, purely from the compiler, with zero hooks in your source.
  4. Deliberately break a Rule of React in one component — e.g. mutate products directly during render instead of deriving a new array — and confirm via the ESLint plugin that the compiler reports it bailed out for that component.
Hint

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?

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?

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?

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?

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.