useCallback Hook

Every time a function component re-renders, any function defined inside it is recreated from scratch — a brand-new function value in memory, even if the code looks identical to the previous render. Usually that’s harmless, but it becomes a problem when that function is passed as a prop to a memoized child component, or used as a dependency in another hook like useEffect. useCallback solves this by returning the same function reference across renders, as long as its dependencies haven’t changed.

Overview / How it works

In React, a component re-renders whenever its state or props change. When that happens, the entire function body runs again from top to bottom — including any function declarations inside it. Consider a component that defines const handleClick = () => setCount(count + 1). On every render, JavaScript creates a new function object for handleClick, distinct from the one created on the previous render, even though the code is the same. Two functions with identical code are still two different objects in memory — comparing them with === returns false.

Normally this doesn’t matter. But if that function is passed down to a child component wrapped in memo(), the child compares its previous props to its new props with a shallow equality check. Since the function reference changed, memo sees a “different” prop and re-renders the child anyway — defeating the whole point of memoizing it. The same problem shows up when a function is listed in a useEffect dependency array: if the function is recreated every render, the effect that depends on it fires every render too, even when nothing meaningful changed.

useCallback(fn, deps) fixes this by caching the function across renders. React stores the function from the first render and, on subsequent renders, compares the new deps array to the previous one item-by-item using Object.is. If every dependency is unchanged, React throws away the newly created function and hands back the exact same reference from before. Only when a dependency actually changes does React let the new function through and update its cache. This is the same underlying mechanism useMemo uses — in fact, useCallback(fn, deps) is functionally equivalent to useMemo(() => fn, deps). The difference is purely about intent: useCallback memoizes a function value, useMemo memoizes any computed value.

It’s important to understand what useCallback does not do: it doesn’t prevent the component that calls it from re-rendering, and it doesn’t make the function “faster.” It only stabilizes the function’s identity between renders. Its value only shows up when something downstream actually cares about that identity — a memoized child, a dependency array, or a ref comparison.

Syntax

const memoizedFn = useCallback(() => {
  // function body
}, [dep1, dep2]);
  • First argument — the function you want React to memoize. It can take any arguments and return any value; useCallback doesn’t call it, it just wraps it.
  • Second argument (dependency array) — a list of every reactive value (state, props, or other variables from the component scope) that the function reads. React compares this array to the previous render’s array to decide whether to return the old function or the new one.
  • Return value — the memoized function itself, ready to be called or passed as a prop. It is not a wrapped or altered version — calling it behaves exactly like calling the original function.

Examples

Example 1: Preventing a memoized child from re-rendering

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

function IncrementButton({ onIncrement }) {
  console.log("IncrementButton rendered");
  return <button onClick={onIncrement}>Increment</button>;
}

const MemoIncrementButton = memo(IncrementButton);

function Counter() {
  const [count, setCount] = useState(0);
  const [text, setText] = useState("");

  const handleIncrement = useCallback(() => {
    setCount((c) => c + 1);
  }, []);

  return (
    <div>
      <p>Count: {count}</p>
      <MemoIncrementButton onIncrement={handleIncrement} />
      <input value={text} onChange={(e) => setText(e.target.value)} />
    </div>
  );
}

export default Counter;

Output:

IncrementButton rendered   (only on first render and when count changes)

This renders a count display, an Increment button, and a text input. Typing in the input updates text and re-renders Counter, but because handleIncrement is wrapped in useCallback with an empty dependency array, the same function reference is passed to MemoIncrementButton every time. Since memo sees an unchanged onIncrement prop, IncrementButton does not re-render while you type — only when Increment is clicked and count actually changes. The c => c + 1 functional update form is used specifically so count never needs to be a dependency.

Example 2: A memoized handler for items in a list

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

const TodoItem = memo(function TodoItem({ todo, onToggle }) {
  console.log("Rendering:", todo.text);
  return (
    <li>
      <label>
        <input
          type="checkbox"
          checked={todo.done}
          onChange={() => onToggle(todo.id)}
        />
        {todo.text}
      </label>
    </li>
  );
});

function TodoList() {
  const [todos, setTodos] = useState([
    { id: 1, text: "Learn useCallback", done: false },
    { id: 2, text: "Build a project", done: false },
  ]);

  const handleToggle = useCallback((id) => {
    setTodos((prev) =>
      prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t))
    );
  }, []);

  return (
    <ul>
      {todos.map((todo) => (
        <TodoItem key={todo.id} todo={todo} onToggle={handleToggle} />
      ))}
    </ul>
  );
}

export default TodoList;

This renders a checklist of two todo items. Clicking a checkbox toggles that item’s done state by creating a new array with the matching item replaced by a new object — state is never mutated directly. Because handleToggle uses the functional updater setTodos((prev) => ...), it never needs todos in its dependency array, so it stays stable across renders. Combined with memo on TodoItem, toggling one todo re-renders only that item, not the whole list.

Example 3: Pairing useCallback with useEffect

import { useState, useEffect, useCallback } from "react";

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  const fetchUser = useCallback(async () => {
    const res = await fetch(`/api/users/${userId}`);
    const data = await res.json();
    setUser(data);
  }, [userId]);

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

  if (!user) return <p>Loading...</p>;
  return <h3>{user.name}</h3>;
}

export default UserProfile;

This renders “Loading…” until the fetch resolves, then shows the user’s name. fetchUser is memoized with userId as its only dependency, so it’s only recreated when userId actually changes. Listing fetchUser in the useEffect dependency array (rather than disabling the lint rule or omitting it) lets the effect correctly re-run whenever userId changes, without also re-running on every unrelated re-render of UserProfile.

How it works step by step

  • On mount: React runs the component function, encounters useCallback, and stores both the function and the dependency array in that hook’s slot in the component’s internal state (in the same order every render — this is why hooks can never be conditional).
  • On a re-render: React runs the component function again. When it reaches useCallback, it compares the new dependency array to the stored one, item by item, using Object.is. If all items are equal, React discards the freshly created function and returns the cached one. If any item differs, React stores the new function and new dependency array, and returns the new function.
  • Downstream effect: Whatever received that memoized function — a memo-wrapped child’s props, or a useEffect/useMemo dependency array — sees a stable reference across renders where the dependencies haven’t changed, so it can skip unnecessary work.
  • On unmount: The hook’s stored value is discarded along with the rest of the component’s internal state; there is no cleanup step specific to useCallback itself (unlike useEffect).

Common Mistakes

Mistake 1: Omitting a dependency causes a stale closure

function SearchBox() {
  const [query, setQuery] = useState("");
  const [results, setResults] = useState([]);

  const handleSearch = useCallback(() => {
    fetch(`/api/search?q=${query}`)
      .then((res) => res.json())
      .then(setResults);
  }, []); // missing `query` dependency

  return (
    <div>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <button onClick={handleSearch}>Search</button>
    </div>
  );
}

Because the dependency array is empty, handleSearch is created once on the first render and “closes over” the value of query at that time — an empty string. No matter what the user types afterward, clicking Search always fetches /api/search?q=. The fix is to include every reactive value the function reads:

const handleSearch = useCallback(() => {
  fetch(`/api/search?q=${query}`)
    .then((res) => res.json())
    .then(setResults);
}, [query]);

Mistake 2: Wrapping a function that gets an unstable dependency anyway

function Parent() {
  const options = { sortBy: "name" }; // new object literal every render

  const handleSort = useCallback(() => {
    console.log(options);
  }, [options]); // options is never equal to its previous value

  return <Child onSort={handleSort} />;
}

options is a new object literal on every render of Parent, so it never equals the previous render’s options by reference — even though its contents are identical. That makes the dependency array “change” every render, so useCallback produces a new handleSort every time anyway, providing zero benefit. Fix it by moving the object outside the component (if it’s truly static), memoizing it with useMemo, or deriving only the primitive values the callback actually needs (options.sortBy) as the dependency instead of the whole object.

Best Practices

  • Don’t reach for useCallback by default — it only pays off when the function is passed to a memo-wrapped component or used in another hook’s dependency array. Wrapping every handler “just in case” adds overhead and clutter for no benefit.
  • Always list every state, prop, or outer-scope variable the function actually reads in the dependency array — don’t disable the exhaustive-deps lint rule to make warnings go away.
  • Prefer functional state updates (setCount(c => c + 1)) over reading state directly inside the callback — it often lets you drop that state value from the dependency array entirely, keeping the function stable longer.
  • Pair useCallback with memo on the receiving component; using one without the other usually accomplishes nothing.
  • Watch out for inline object/array/function literals passed as dependencies — they’re recreated every render and silently defeat memoization. Stabilize them first with useMemo or useCallback, or depend on their primitive fields instead.
  • Remember useCallback(fn, deps) is equivalent to useMemo(() => fn, deps) — reach for whichever name better communicates intent at the call site.

Practice Exercises

  • Build a Counter component with a memoized child DisplayCount (wrapped in memo) that logs to the console on every render. Add an unrelated state value (like a text input) to Counter and verify, using the console logs, that DisplayCount only re-renders when the count itself changes — first without useCallback, then with it.
  • Write a Timer component that uses useCallback to memoize a tick function depending on a step state value, and pass it into a useEffect that sets up a setInterval. Make sure changing step correctly restarts the interval with the new value, with no stale closures.
  • Take the SearchBox mistake shown above, intentionally reintroduce the missing-dependency bug, and describe in your own words exactly what a user would observe when typing a query and clicking Search — then fix it and confirm the behavior changes.

Summary

  • useCallback(fn, deps) returns the same function reference across renders as long as the values in deps haven’t changed, instead of creating a brand-new function every render.
  • It matters only when that stable reference is consumed somewhere — a memo-wrapped child’s props, or a dependency array in useEffect/useMemo/another useCallback.
  • useCallback(fn, deps) is equivalent to useMemo(() => fn, deps); it’s a specialized case for memoizing functions specifically.
  • Always include every reactive value the function reads in the dependency array to avoid stale closures — use functional state updates to reduce how many dependencies you need.
  • Unstable dependencies (new object/array/function literals created every render) silently defeat memoization; stabilize them too.
  • Don’t overuse it — useCallback without a memoized consumer adds cost without benefit.