React

Stop Using "Useless" useCallback! 3 Better Ways 2025

Tired of overusing React's useCallback hook? Discover why it's often useless and learn 3 superior patterns for 2025 to boost performance and code clarity.

A

Alex Miller

Senior Frontend Engineer specializing in React performance optimization and modern web architecture.

7 min read10 views

What is useCallback and Why is it Overused?

If you've been working with React for any length of time, you've seen useCallback. It’s one of the standard hooks we reach for when we think “optimization.” The promise is simple: give it a function and a dependency array, and it will return a memoized version of that function. This memoized function only changes if a dependency has changed.

The goal? To prevent unnecessary re-renders in child components by preserving referential equality. When a parent component re-renders, it re-creates all its functions. If you pass a newly created function to a child component wrapped in React.memo, that child will re-render because the function prop is a new object in memory, even if the code is identical.

This sounds great, and it led to a common pattern: wrap every function passed as a prop in useCallback. But here's the hard truth for 2025: this is often a premature, and sometimes harmful, optimization. Most of the time, you're adding complexity and overhead for zero gain. Let's explore why this happens and what to do instead.

The Performance Trap: When useCallback Hurts More Than It Helps

Using useCallback isn't free. It comes with its own performance and complexity costs. Before wrapping another function, consider what you're actually paying for.

The Hidden Cost of Memoization

Every time your component renders, useCallback has to do work. It needs to:

  1. Create and store the dependency array: This is a new array that gets allocated on every render.
  2. Compare the dependencies: React iterates over the new dependency array and compares each item with the previous one.
  3. Maintain the memoized function in memory: This adds to the component's memory footprint.

For simple functions or components that render infrequently, the cost of this memoization check can be greater than the benefit of preventing a child's re-render. Modern JavaScript engines are incredibly fast at creating functions, and React's reconciliation is highly optimized. You might be “optimizing” something that was never a problem to begin with.

The most common misuse: Wrapping an event handler passed to a native DOM element like <button> or <div>.

// 👎 This is almost always useless
const MyButton = ({ user }) => {
  const handleClick = useCallback(() => {
    console.log(`Clicked by ${user.name}`);
  }, [user.name]);

  // A native button doesn't care about the function's reference!
  return <button onClick={handleClick}>Click Me</button>;
};

Native elements are not React.memo components. They don't care about referential equality for their event handlers. The re-render of the parent component will cause the button to re-render anyway. The useCallback here adds overhead for no benefit.

The Stale Closure Pitfall

A more dangerous issue is the stale closure. This happens when you use useCallback with an empty dependency array ([]) for a function that relies on state or props. The function gets memoized on the initial render and never updates, “closing over” the initial values of your state and props.

// 🐛 A classic bug waiting to happen
const Counter = () => {
  const [count, setCount] = useState(0);

  // This function will ALWAYS log `count` as 0
  const logCount = useCallback(() => {
    console.log(`The current count is: ${count}`);
  }, []); // Oops! Forgot `count` as a dependency

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(c => c + 1)}>Increment</button>
      <button onClick={logCount}>Log Count</button>
    </div>
  );
};

In this example, no matter how many times you increment, `logCount` will always report the count is 0. The fix is to add `count` to the dependency array, but this brings us back to the original problem: now the function reference changes whenever `count` changes anyway, potentially defeating the purpose of memoizing it for a child component.

3 Superior Patterns for Cleaner, Faster React Apps

Instead of reaching for useCallback by default, consider these more effective and often simpler patterns for managing component renders and state.

Pattern 1: Embrace Function Redefinition (It's Okay!)

This is the simplest pattern of all: just define your function directly in your component. For the vast majority of cases, especially when passing handlers to DOM elements or non-memoized components, this is perfectly fine. The performance cost is negligible, and the code is far more readable.

// 👍 Simple, readable, and usually performant enough
const UserProfile = ({ user }) => {
  const handleFollow = () => {
    // API call to follow the user
    console.log(`Following ${user.name}`);
  };

  return (
    <div>
      <h3>{user.name}</h3>
      <button onClick={handleFollow}>Follow</button>
    </div>
  );
};

When to use this: Your default approach. Only move away from this pattern if you have identified a clear performance bottleneck with profiling tools.

Pattern 2: Colocate State for True Simplicity

Often, the need for useCallback arises from prop-drilling: passing state and update functions through multiple layers of intermediate components. A much better solution is to move the state closer to where it's used. This is called state colocation.

By restructuring your components, you can often eliminate the need to pass callbacks down altogether. This not only removes the need for useCallback but also makes your component tree much easier to understand and maintain.

// 👎 Before: Prop drilling leads to useCallback temptation
const App = () => {
  const [name, setName] = useState('');
  const handleNameChange = useCallback((e) => setName(e.target.value), []);
  return <ProfileForm onNameChange={handleNameChange} name={name} />;
};
const ProfileForm = ({ name, onNameChange }) => <NameInput name={name} onNameChange={onNameChange} />;
const NameInput = ({ name, onNameChange }) => <input value={name} onChange={onNameChange} />;

// 👍 After: State is colocated, no prop drilling!
const App = () => <ProfileForm />;
const ProfileForm = () => <NameInput />;
const NameInput = () => {
  const [name, setName] = useState('');
  const handleNameChange = (e) => setName(e.target.value);
  return <input value={name} onChange={handleNameChange} />;
};

If you need to share the state across distant components, that's a sign you should use React Context or a state management library, which often provide their own solutions to this problem.

Pattern 3: Use `useReducer` for a Stable Dispatch

When you have complex state logic involving multiple update functions, useReducer is a fantastic alternative. The `dispatch` function it returns is guaranteed to be stable across re-renders. You don't need to wrap it in useCallback.

This allows you to pass a single, stable `dispatch` function down to child components, which can then dispatch various actions to update the state.

// 👍 Stable dispatch for free with useReducer
const initialState = { count: 0, step: 1 };

function reducer(state, action) {
  switch (action.type) {
    case 'increment': return { ...state, count: state.count + state.step };
    case 'decrement': return { ...state, count: state.count - state.step };
    case 'setStep': return { ...state, step: action.payload };
    default: throw new Error();
  }
}

const ComplexCounter = () => {
  const [state, dispatch] = useReducer(reducer, initialState);

  // `dispatch` is stable. No useCallback needed!
  // You can pass it down to any child component.
  return (
    <div>
      <p>Count: {state.count}</p>
      <CounterControls dispatch={dispatch} />
    </div>
  );
};

This pattern scales beautifully and is a cornerstone of robust state management within complex components, entirely sidestepping the `useCallback` dilemma.

Comparison: useCallback vs. Modern Alternatives

Choosing Your Optimization Strategy
StrategyBest Use CasePerformance ImpactReadability
useCallbackFunctions passed to React.memo children or as hook dependencies.Small overhead; can be negative if misused.Adds complexity; dependency array can be tricky.
Function RedefinitionDefault for most components, especially with native elements.Negligible for most cases; let React handle it.Excellent. Simple and straightforward.
State ColocationWhen state is only needed by a small subtree of components.Positive. Prevents unnecessary re-renders of parent components.Excellent. Makes data flow logical and easy to trace.
useReducerComplex state logic with multiple actions in one component.Excellent. Provides a stable `dispatch` function for free.Good. Centralizes logic, though requires reducer setup.

So, When Should I *Actually* Use useCallback?

Despite its overuse, useCallback is not obsolete. It is a specialized tool that is crucial in specific scenarios:

  1. Passing Callbacks to Memoized Children: This is the primary use case. If you have a child component wrapped in React.memo that accepts a function prop, you must use useCallback to prevent it from re-rendering unnecessarily.
  2. As a Dependency for Other Hooks: If a function is used inside a useEffect, useMemo, or another custom hook, and you don't want the hook to re-run every time the component renders, you should wrap that function in useCallback.
// ✅ A valid use case for useCallback
const SearchResults = React.memo(({ onResultClick }) => {
  // ... renders a list of results
});

const SearchPage = () => {
  const [query, setQuery] = useState('');

  // Without useCallback, a new `handleResultClick` would be created
  // on every render (e.g., when typing in an input), causing
  // SearchResults to re-render needlessly.
  const handleResultClick = useCallback((result) => {
    console.log(`Navigating to ${result.id}`);
  }, []); // Assuming no external dependencies

  return (
    <div>
      <input type="text" value={query} onChange={e => setQuery(e.target.value)} />
      <SearchResults onResultClick={handleResultClick} />
    </div>
  );
};

Conclusion: Optimize with Intent, Not by Habit

The evolution of React and JavaScript engines has made many old “best practices” obsolete. Blindly wrapping every function in useCallback is a relic of a time when we were more concerned with micro-optimizations. In 2025, the focus should be on writing clean, readable, and maintainable code first.

Before you type useCallback, ask yourself:

  • Am I passing this to a component wrapped in React.memo?
  • Is this function a dependency of another hook like useEffect?
  • Have I actually measured a performance problem here?

If the answer to all of these is “no,” you probably don't need it. Embrace simpler patterns like state colocation and useReducer. Trust that React is fast enough for most cases. Your future self—and your teammates—will thank you for the cleaner, more intentional codebase.