React useCallback: When to Use It

5 min read

React’s useCallback hook is one of the most misunderstood tools in a developer’s toolbox. Misusing it can add complexity without any benefit, while using it smartly can prevent unnecessary renders and subtle bugs. In this post, we’ll dive into when to use it, when not to use it, and why it’s really about stable references — not stopping function creation.


What is useCallback?

useCallback is a React hook that returns a memoized version of a callback function. It only changes if one of its dependencies has changed. This is useful when passing callbacks to optimized child components that rely on reference equality to prevent unnecessary renders.

const memoizedCallback = useCallback(() => {
    // Function logic
},[dependencies]);

Key points:

  • It does not prevent the function from being recreated. Every render still creates a new function in memory.
  • What it does do is provide a stable reference so React (or other hooks/components) can reliably compare it.
  • Useful for memoized components, useEffect dependencies, debounced/throttled functions, and custom hooks.

When to Use useCallback

  1. Returning Functions from Custom Hooks: When building custom hooks, you often return functions that might be used in useEffect or passed to children. Without useCallback, these functions get new references every render, which can break effects or cause unnecessary re-renders.
function useCounter(initialCount = 0) {
  const [count, setCount] = useState(initialCount);

  const increment = useCallback(() => setCount(c => c + 1), []);
  const decrement = useCallback(() => setCount(c => c - 1), []);

  return { count, increment, decrement };
}

Usage:

function Counter() {
  const { count, increment } = useCounter();

  useEffect(() => {
    const intervalId = setInterval(increment, 1000);
    return () => clearInterval(intervalId);
  }, [increment]);

  return <h1>{count}</h1>;
}

Why it works:

  • Without useCallback, increment would get a new reference every render.
  • Passing a changing function to useEffect would constantly clear and restart the interval.
  • useCallback ensures the function reference is stable, keeping the interval consistent.

  1. Debouncing or Throttling Functions: Debounce and throttle utilities rely on a stable function reference to work correctly. Creating a new function on every render breaks the timer.
function Search() {
  const [searchParams, setSearchParams] = useSearchParams();

  function handleSearch(e: React.ChangeEvent<HTMLInputElement>) {
    setSearchParams({ query: e.target.value });
  }

  const debounceSearch = debounce(handleSearch, 400);

  return (
    <input
      type="text"
      placeholder="Search"
      onChange={debounceSearch} // ❌ Won't debounce correctly
    />
  );
}

Solution with useCallback:

function Search() {
  const [searchParams, setSearchParams] = useSearchParams();

  const handleSearch = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
    setSearchParams({ query: e.target.value });
  }, [setSearchParams]);

  const debounceSearch = debounce(handleSearch, 400);

  return (
    <input
      type="text"
      placeholder="Search"
      onChange={debounceSearch}
    />
  );
}

Why it works:

  • Without useCallback, handleSearch gets a new reference every render.
  • This breaks the debounce logic, as it relies on the same function reference to manage timing.
  • useCallback ensures handleSearch has a stable reference, allowing debounce to function correctly.

  1. Passing Callbacks to Memoized Child Components: If you have a child component wrapped in React.memo, passing a new function reference each render will cause it to re-render unnecessarily.
const Child = React.memo(({ onClick }: { onClick: () => void }) => {
  console.log("Child rendered");
  return <button onClick={onClick}>Click me</button>;
});    

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

  const handleClick = () => setCount(c => c + 1); // New reference every render

  return (
    <div>
      <p>Count: {count}</p>
      <Child onClick={handleClick} /> {/* Child re-renders every time */}
    </div>
  );
}

Solution with useCallback:

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

  const handleClick = useCallback(() => setCount(c => c + 1), []);

  return (
    <div>
      <p>Count: {count}</p>
      <Child onClick={handleClick} /> {/* Child re-renders only when count changes */}
    </div>
  );
}

Why it works:

  • useCallback returns a memoized version of the callback that only changes if one of the dependencies has changed.
  • This means handleClick will have the same reference between renders, preventing unnecessary re-renders of Child.

When Not to Use useCallback

Not every function needs to be memoized. If a function is simple and not passed to memoized components or used in effects, adding useCallback adds unnecessary complexity. Start without it, and only add it when you identify a specific need.

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

  // No need for useCallback here
  const increment = () => setCount(c => c + 1);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={increment}>Increment</button>
    </div>
  );
}

Conclusion

The useCallback hook is a powerful tool for optimizing React applications, but it should be used judiciously. Focus on stable references rather than trying to prevent function creation. Use it when passing functions to memoized components, in custom hooks, or when dealing with debounced/throttled functions. Avoid overusing it in simple scenarios where it adds unnecessary complexity. By understanding when and why to use useCallback, you can write more efficient and maintainable React code.