Skip to main content

🔗 The useCallback Hook

In the last lesson you saw that a fresh object or array prop quietly defeats React.memo. Functions have exactly the same problem — a component re-creates every function it defines on every render, so each is a new value. useCallback is the hook that pins a function's identity down, so the memoized children you hand it to can finally stay put.

Week 5 · Day 4 (Thursday: Performance Optimization) · Lecture 2

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain why functions get a new identity on every render (the "function identity" problem)
  • Use useCallback to memoize a function reference across renders
  • Write correct dependency arrays and describe the stale-closure bug that bad deps cause
  • Distinguish useCallback(fn, deps) from useMemo(() => fn, deps)
  • Combine useCallback with React.memo to keep list items and context consumers stable
  • Recognize over-use of useCallback and decide when it earns its cost

Estimated Time: 65 minutes

Practice: Fix a list whose rows all re-render on every click, and stabilize a context value.

In This Lesson

The Function Identity Problem

Imagine a film director giving actors the same note before every take: "same as we rehearsed." If the director instead re-explained the entire scene from scratch each take, the crew would treat it as brand-new direction and reset everything. That's what happens with functions in React — every render writes a brand-new function, and any component receiving it treats it as a new instruction.

Here's the concrete problem. This child is wrapped in React.memo, yet it re-renders on every parent render:

import { memo, useState } from 'react';

const ExpensiveChild = memo(function ExpensiveChild({ onClick }) {
  console.log('ExpensiveChild rendered');   // logs on EVERY parent render 😞
  return <button onClick={onClick}>Click me</button>;
});

function Parent() {
  const [count, setCount] = useState(0);

  // A brand-new function object is created on every render of Parent
  const handleClick = () => {
    console.log('clicked!');
  };

  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
      {/* handleClick is "new" each render → memo sees changed props → re-render */}
      <ExpensiveChild onClick={handleClick} />
    </div>
  );
}

Even though handleClick does the exact same thing every time, it's a different function value on each render. React.memo shallow-compares props with Object.is, decides prevOnClick !== nextOnClick, and re-renders. The memo is working perfectly — you're just feeding it unstable props.

graph TD A[Parent re-renders] --> B[Define handleClick] B --> C{useCallback?} C -->|No| D[Brand-new function
each render] C -->|Yes, deps unchanged| E[Same function
reference reused] D --> F[memo child: props changed] E --> G[memo child: props equal] F --> H[Child RE-RENDERS] G --> I[Child SKIPS re-render]

useCallback Basics

useCallback memoizes a function. You give it a function and a dependency array; it returns the same function reference on every render until one of the dependencies changes. That's the entire idea.

import { memo, useCallback, useState } from 'react';

function Parent() {
  const [count, setCount] = useState(0);

  // Same reference across renders because deps never change ([])
  const handleClick = useCallback(() => {
    console.log('clicked!');
  }, []);

  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
      {/* Now ExpensiveChild receives a stable prop and skips re-renders */}
      <ExpensiveChild onClick={handleClick} />
    </div>
  );
}

With the empty dependency array, handleClick is created once and reused forever. The memoized child now logs a single time no matter how often you click the counter, because its onClick prop is referentially stable.

📖 The two-part contract, again

useCallback only helps if the receiving component cares about referential equality — i.e. it's wrapped in React.memo, or the function is a dependency of another hook. Memoizing a callback and then passing it to a plain, non-memoized child accomplishes nothing: that child re-renders with its parent regardless.

Dependencies & Stale Closures

The dependency array works exactly like useMemo's: list every value from the surrounding scope that the function reads. Get it wrong and you don't get a crash — you get a stale closure, where the function keeps using an old value it captured on a previous render.

function SearchComponent() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);

  // ✅ Recreated only when `query` changes — always searches the current query
  const handleSearch = useCallback(() => {
    fetchResults(query).then(setResults);
  }, [query]);

  // ✅ Reads no external values except the stable setters → never needs recreating
  const handleClear = useCallback(() => {
    setQuery('');
    setResults([]);
  }, []);

  return (
    <div>
      <SearchInput value={query} onChange={setQuery}
                   onSearch={handleSearch} onClear={handleClear} />
      <SearchResults results={results} />
    </div>
  );
}

⚠️ The stale-closure bug

// 🔴 WRONG: userId is read but missing from deps
function Profile({ userId }) {
  const [data, setData] = useState(null);

  const fetchUser = useCallback(async () => {
    const res = await api.getUser(userId);   // captures the FIRST userId forever
    setData(res);
  }, []);                                     // should be [userId]

  useEffect(() => { fetchUser(); }, [fetchUser]);
  // When userId changes, fetchUser still fetches the OLD user. 🐞
}

Because userId was left out, fetchUser is never recreated, so it forever closes over the first userId. Add userId to the array and the function updates when it should. The exhaustive-deps ESLint rule catches this automatically.

✅ Tip: pass values as arguments to avoid dependencies

// No dependency needed — the value comes in as a parameter
const fetchUser = useCallback(async (id) => {
  const res = await api.getUser(id);
  setData(res);
}, []);

useEffect(() => { fetchUser(userId); }, [fetchUser, userId]);

When a function can receive what it needs as an argument, it stays dependency-free and maximally stable. Similarly, the functional setter form (setCount(c => c + 1)) lets a callback update state without depending on the current state value.

useCallback vs useMemo

These two hooks are close cousins. The difference is small but worth nailing down: useMemo caches the result of calling a function; useCallback caches the function itself.

useMemouseCallback
Cachesthe value a function returnsthe function reference
Runs the function?Yes — during render, to get the valueNo — just stores it for later calls
Typical useexpensive calculation, stable object/arraystable callback for a memo child or hook dep
EquivalentuseMemo(() => x, d)useCallback(fn, d) === useMemo(() => fn, d)

In fact useCallback(fn, deps) is exactly shorthand for useMemo(() => fn, deps). React added useCallback because memoizing functions is so common that the shorthand is worth having.

// These two lines are equivalent:
const memoFn = useCallback((id) => doThing(id), [dep]);
const memoFn = useMemo(() => (id) => doThing(id), [dep]);

Real-World Patterns

1. Optimizing list rendering

The classic payoff: a list where each row is a memoized component. Define the handler once in the parent with useCallback, and clicking or selecting one row won't re-render the other hundred.

function OptimizedList({ items }) {
  const [selectedId, setSelectedId] = useState(null);

  // One stable handler shared by every row
  const handleSelect = useCallback((id) => {
    setSelectedId(id);
  }, []);

  return (
    <div>
      {items.map(item => (
        <ListItem
          key={item.id}
          item={item}
          isSelected={item.id === selectedId}
          onSelect={handleSelect}
        />
      ))}
    </div>
  );
}

const ListItem = memo(function ListItem({ item, isSelected, onSelect }) {
  console.log(`Rendering ${item.id}`);   // now only the changed rows log
  return (
    <div
      className={`list-item ${isSelected ? 'selected' : ''}`}
      onClick={() => onSelect(item.id)}
    >
      {item.title}
    </div>
  );
});

Because onSelect is stable and item is stable, the only prop that changes for most rows is nothing at all — memo skips them. Only the row whose isSelected flips actually re-renders.

2. Stabilizing a context value's methods

Context is a sneaky performance sink: every consumer re-renders when the provider's value changes reference. Memoize the methods with useCallback and the whole value object with useMemo, and consumers only update when real data changes.

function AuthProvider({ children }) {
  const [user, setUser] = useState(null);

  const login = useCallback(async (credentials) => {
    const data = await api.login(credentials);
    setUser(data);
  }, []);

  const logout = useCallback(() => {
    setUser(null);
  }, []);

  // Stable value object: new only when user / login / logout change
  const value = useMemo(
    () => ({ user, login, logout }),
    [user, login, logout]
  );

  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}

3. Callbacks that interact with a ref

Media controls, focus management, and similar imperative code pair naturally with useCallback — the callbacks touch a ref, not reactive state, so an empty dependency array is correct and they stay perfectly stable.

function VideoPlayer({ src }) {
  const videoRef = useRef(null);
  const [isPlaying, setIsPlaying] = useState(false);

  const play = useCallback(() => {
    videoRef.current?.play();
    setIsPlaying(true);
  }, []);

  const pause = useCallback(() => {
    videoRef.current?.pause();
    setIsPlaying(false);
  }, []);

  // Depends on play/pause (both stable) and isPlaying
  const togglePlay = useCallback(() => {
    isPlaying ? pause() : play();
  }, [isPlaying, play, pause]);

  return (
    <div>
      <video ref={videoRef} src={src} />
      <VideoControls isPlaying={isPlaying} onPlayPause={togglePlay} />
    </div>
  );
}

When NOT to Use It

useCallback is not free. It runs on every render, allocates the dependency array, and adds visual noise. If the memoized function isn't feeding a React.memo child or a hook dependency, you're paying the cost for nothing.

// 🔴 Over-optimized: these callbacks go straight onto plain DOM buttons.
// Nothing downstream cares about their identity, so useCallback is pure overhead.
function Counter() {
  const [count, setCount] = useState(0);
  const increment = useCallback(() => setCount(c => c + 1), []);
  const reset     = useCallback(() => setCount(0), []);
  return (
    <div>
      <button onClick={increment}>+</button>
      <button onClick={reset}>Reset</button>
      <span>{count}</span>
    </div>
  );
}

// ✅ Just write the inline handlers — clearer and no cost:
function CounterSimple() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>+</button>
      <button onClick={() => setCount(0)}>Reset</button>
      <span>{count}</span>
    </div>
  );
}

💡 The one-line rule

Reach for useCallback when the function is (1) passed to a component wrapped in React.memo, or (2) listed in another hook's dependency array. Otherwise, an inline function is simpler and just as fast.

Practice & Quiz

🏋️ Exercise 1: Stop every row from re-rendering

Goal: Each Row is memoized, yet clicking one logs a render for all rows. Fix the parent so only the affected row re-renders.

const Row = memo(function Row({ item, onPick }) {
  console.log('Row', item.id, 'rendered');
  return <li onClick={() => onPick(item.id)}>{item.name}</li>;
});

function List({ items }) {
  const [picked, setPicked] = useState(null);
  const onPick = (id) => setPicked(id);   // 🐞 new function every render
  return (
    <ul>
      {items.map(it => <Row key={it.id} item={it} onPick={onPick} />)}
    </ul>
  );
}
💡 Hint

onPick is recreated on every render, so all memoized rows see a "new" prop. Memoize it with useCallback and an empty dependency array (the functional setter means it needs no deps).

✅ Solution
function List({ items }) {
  const [picked, setPicked] = useState(null);
  const onPick = useCallback((id) => setPicked(id), []);
  return (
    <ul>
      {items.map(it => <Row key={it.id} item={it} onPick={onPick} />)}
    </ul>
  );
}
// Now only the row you click re-renders; the rest stay quiet.

🏋️ Exercise 2: Fix the stale closure

Goal: This search callback always searches the first term the user typed. Correct the dependency array so it uses the current term.

function Search() {
  const [term, setTerm] = useState('');
  const runSearch = useCallback(() => {
    api.search(term);   // always uses the very first term 🐞
  }, []);
  return (
    <>
      <input value={term} onChange={e => setTerm(e.target.value)} />
      <button onClick={runSearch}>Search</button>
    </>
  );
}
✅ Solution
const runSearch = useCallback(() => {
  api.search(term);
}, [term]);   // recreate whenever `term` changes → always current
// (Alternatively, pass the term in: useCallback((t) => api.search(t), []).)

🎯 Quick Quiz

Question 1: What exactly does useCallback memoize?

Question 2: You memoize a callback with useCallback and pass it to a plain (non-memoized) child. What's the effect on re-renders?

Question 3: A useCallback reads userId but omits it from the dependency array. What bug results?

Best Practices & Pitfalls

✅ Do

  • Use useCallback for functions passed to React.memo children or listed in hook dependency arrays
  • Include every value the function reads in the dependency array
  • Prefer the functional setter (setX(prev => ...)) so callbacks don't depend on current state
  • Pass values as arguments when you can, to keep the dependency array empty
  • Pair it with useMemo when stabilizing a whole context value object

❌ Don't

  • Wrap every event handler in useCallback — inline handlers on plain DOM elements are fine
  • Drop a dependency to stop a function from "changing" — that ships a stale closure
  • Expect useCallback to help a child that isn't memoized
  • Depend on a whole object ([config]) when you only read one field ([config.threshold])

⚠️ Narrow your dependencies

// 🔴 Recreated whenever ANY field of config changes reference
const filter = useCallback((data) =>
  data.filter(x => x.value > config.threshold), [config]);

// ✅ Depend only on the value you actually use
const filter = useCallback((data) =>
  data.filter(x => x.value > config.threshold), [config.threshold]);

Depending on a whole object reintroduces the very referential-equality problem you're trying to avoid. Depend on the primitive field instead.

Summary

🎉 Key Takeaways

  • Functions get a new identity every render, which defeats React.memo on the receiving child
  • useCallback(fn, deps) returns the same function reference until a dependency changes
  • It only helps when the callback feeds a memoized child or a hook dependency
  • Missing dependencies cause stale closures — real bugs, not just perf issues
  • useCallback(fn, d) is exactly useMemo(() => fn, d)
  • Don't over-use it — inline handlers on plain elements are simpler and just as fast

📚 Additional Resources

🚀 What's Next?

You've squeezed re-renders out of individual components. The next lesson zooms out to the whole bundle: Code splitting and lazy loading — using React.lazy and Suspense to load only the JavaScript a user actually needs, when they need it.

🔗 Locked in!

Function identity used to be an invisible trap. Now you can pin it down deliberately — and know when it's not worth the trouble.