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 13 Week 3 of 20 ~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. Hands-on Exercise

Hands-on

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:

  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.

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