Back to Blog
·4 min read

Why I stopped using React's useCallback everywhere

useCallback seems like a performance optimization, but overusing it actually makes your app slower and harder to maintain.

AI Dev
react
performance
usecallback
optimization

Why I stopped using React's useCallback everywhere

I used to wrap every function in useCallback. It felt like the responsible thing to do -- prevent unnecessary re-renders, optimize performance, follow React best practices. The pattern was simple: any function that gets passed to a child component or used in a dependency array gets wrapped in useCallback. Most performance guides recommended this approach, and my linter even had rules that enforced it. Then I profiled a dashboard component that was running slower after I "optimized" it with dozens of useCallback hooks.

The wake-up call came when I ran React's Profiler on a component that I had carefully optimized with useCallback everywhere. The component was actually rendering more frequently than before my optimizations. I had created a maze of dependency arrays where changing one prop would invalidate multiple memoized functions, causing cascading re-renders throughout the component tree. When I needed to add a simple onClick handler, I found myself updating four different dependency arrays just to prevent the linter from complaining.

That's when I learned that memoization is not free. Every useCallback hook has overhead -- React needs to store the previous function, compare dependency arrays, and decide whether to return the cached version or create a new one. For functions that change frequently or have many dependencies, this overhead can actually make your app slower.

The real cost of useCallback everywhere

Here's a component that demonstrates the problem:

function ProductList({ products, filters, onSort }) {
  // These callbacks change on every render anyway
  const handleFilterChange = useCallback((filterId, value) => {
    setFilters(prev => ({ ...prev, [filterId]: value }))
  }, [setFilters]) // setFilters changes every render
 
  const handleProductClick = useCallback((productId) => {
    analytics.track('product_clicked', { productId, filters })
  }, [filters]) // filters change frequently
 
  const handleAddToCart = useCallback((product) => {
    addToCart(product)
    showNotification(`Added ${product.name} to cart`)
  }, [addToCart, showNotification]) // Both functions change every render
 
  // More useCallback hooks...
}

Every time filters changes, three useCallback hooks need to recompute. The dependency array comparisons happen on every render, and most of the time we're creating new functions anyway. I was spending CPU cycles to "optimize" functions that were never actually reused.

My new approach to useCallback

I now use useCallback strategically, only when I can measure a real performance benefit:

function ProductList({ products, onSort }) {
  // Skip useCallback for simple event handlers
  const handleFilterChange = (filterId, value) => {
    setFilters(prev => ({ ...prev, [filterId]: value }))
  }
 
  // Use useCallback when passing to expensive child components
  const handleSort = useCallback((sortBy) => {
    onSort(sortBy)
  }, [onSort])
 
  return (
    <div>
      <FilterPanel onChange={handleFilterChange} />
      <ExpensiveProductTable 
        products={products}
        onSort={handleSort} // This child has React.memo
      />
    </div>
  )
}

I only use useCallback when:

  • Passing functions to memoized child components -- If the child uses React.memo, stable function references prevent unnecessary re-renders
  • Functions with expensive computations -- When the function itself does heavy work and has stable dependencies
  • Functions in useEffect dependencies -- To prevent infinite re-render loops

What I do instead

For most cases, I rely on these alternatives:

Move state down to reduce the scope of re-renders:

// Instead of managing filter state in the parent
function ProductList({ products }) {
  return (
    <div>
      <FilterPanel /> {/* Manages its own filter state */}
      <ProductTable products={products} />
    </div>
  )
}

Use refs for stable references when you need them:

function useStableCallback(callback) {
  const callbackRef = useRef(callback)
  callbackRef.current = callback
  
  return useCallback((...args) => {
    return callbackRef.current(...args)
  }, [])
}

Profile first, optimize second. I use React DevTools Profiler to identify actual performance bottlenecks before adding any memoization.

The key insight is that React is already fast. Most components re-render in microseconds. Adding useCallback everywhere often creates more problems than it solves -- harder-to-read code, complex dependency management, and sometimes worse performance. I now write clean, readable code first, then optimize the specific parts that actually need it.