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.
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.