useCallback in React: The Ultimate Guide to Optimizing Performance and Preventing Costly Re-renders

useCallback in React: The Ultimate Guide to Optimizing Performance and Preventing Costly Re-renders

By Mikey SharmaAug 21, 2025

Optimizing React Performance with useCallback: A Comprehensive Guide

Introduction

Have you ever noticed your React application slowing down as it grows more complex? Or seen components re-rendering unnecessarily, even when their props haven't changed? If so, you've encountered a common performance challenge in React development.

In this comprehensive guide, we'll explore how the useCallback hook can help solve these performance issues. Whether you're a seasoned developer or just starting with React, this post will give you a deep understanding of when and how to use useCallback effectively.

Understanding the Problem: Why Functions Matter in React

Before we dive into solutions, let's understand the core issue.

In JavaScript, functions are objects, and each time you define a function, it creates a new object in memory:

function createFunction() {
  return function() { console.log('Hello!'); };
}

const func1 = createFunction();
const func2 = createFunction();

console.log(func1 === func2); // false - they're different objects!

This becomes important in React because when a component re-renders, all functions defined inside it are recreated. If you pass these functions as props to child components, those children will see them as "new" props every time, potentially causing unnecessary re-renders.

Let's visualize this problem:

Loading diagram...

This is where useCallback comes to the rescue!

What is useCallback?

useCallback is a React Hook that memoizes functions. In simple terms, it "remembers" your function between renders and only creates a new one when specific dependencies change.

Basic Syntax

import { useCallback } from 'react';

const memoizedCallback = useCallback(
  () => {
    // Your function logic here
    doSomething(a, b);
  },
  [a, b], // Dependency array
);

The function will only be recreated when any value in the dependency array changes.

When to Use useCallback

1. Preventing Child Component Re-renders

The most common use case for useCallback is when passing callbacks to optimized child components that rely on reference equality to prevent unnecessary renders (like components wrapped in React.memo).

Without useCallback:

import React, { useState } from 'react';

// Child component wrapped in React.memo
const ExpensiveChild = React.memo(({ onClick, data }) => {
  console.log('Child rendered!');
  return {data};
});

function Parent() {
  const [count, setCount] = useState(0);
  const [data, setData] = useState('Click me');
  
  const handleClick = () => {
    setCount(count + 1);
  };
  
  return (
    
      Count: {count}
      
    
  );
}

In this example, every time Parent re-renders (which happens when count changes), handleClick is recreated, causing ExpensiveChild to re-render even though its visual output wouldn't change.

With useCallback:

import React, { useState, useCallback } from 'react';

const ExpensiveChild = React.memo(({ onClick, data }) => {
  console.log('Child rendered!');
  return {data};
});

function Parent() {
  const [count, setCount] = useState(0);
  const [data, setData] = useState('Click me');
  
  const handleClick = useCallback(() => {
    setCount(count + 1);
  }, [count]); // Only recreate when count changes
  
  return (
    
      Count: {count}
      
    
  );
}

Now, ExpensiveChild won't re-render when Parent updates for reasons unrelated to the handleClick function.

Let's visualize how this optimization works:

Loading diagram...

2. useCallback with useEffect

Another important use case is when your function is a dependency of another Hook like useEffect.

Problematic code:

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  
  const fetchUser = () => {
    fetch(`/api/users/${userId}`)
      .then(response => response.json())
      .then(userData => setUser(userData));
  };
  
  useEffect(() => {
    fetchUser();
  }, [fetchUser]); // This causes an infinite loop!
  
  return {user?.name};
}

This creates an infinite loop because fetchUser is recreated on every render, which triggers the useEffect, which causes a re-render, and so on.

Solution with useCallback:

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  
  const fetchUser = useCallback(() => {
    fetch(`/api/users/${userId}`)
      .then(response => response.json())
      .then(userData => setUser(userData));
  }, [userId]); // Only recreate when userId changes
  
  useEffect(() => {
    fetchUser();
  }, [fetchUser]);
  
  return {user?.name};
}

Now the effect only runs when fetchUser actually changes, which only happens when userId changes.

3. Optimizing Custom Hooks

If you're creating custom hooks that return functions, useCallback can ensure those functions maintain stable references:

function useToggle(initialState = false) {
  const [value, setValue] = useState(initialState);
  
  const toggle = useCallback(() => {
    setValue(currentValue => !currentValue);
  }, []);
  
  return [value, toggle];
}

// Usage
function Component() {
  const [isOn, toggleIsOn] = useToggle(false);
  
  return (
    
      {isOn ? 'ON' : 'OFF'}
    
  );
}

The toggle function maintains a stable reference, making it safe to use in dependency arrays.

When NOT to Use useCallback

While useCallback is useful, it's not always the right tool for the job. Overusing it can actually harm performance rather than help it.

Don't use useCallback for simple components:

// ❌ Unnecessary use of useCallback
function SimpleButton({ onClick, label }) {
  const handleClick = useCallback(() => {
    onClick();
  }, [onClick]);
  
  return {label};
}

// ✅ Better approach
function SimpleButton({ onClick, label }) {
  return {label};
}

Don't use useCallback without a clear purpose:

// ❌ Using useCallback without actual need
function Component() {
  const [count, setCount] = useState(0);
  
  const increment = useCallback(() => {
    setCount(c => c + 1);
  }, []);
  
  // This function isn't passed to optimized children
  // or used in effects, so useCallback is unnecessary
  return {count};
}

Remember: useCallback itself has a cost (memory allocation and comparison logic), so only use it when the benefits outweigh that cost.

Advanced Patterns and Tips

1. useCallback with useReducer

For complex state logic, consider combining useCallback with useReducer:

function TodoApp() {
  const [todos, dispatch] = useReducer(todosReducer, []);
  
  const addTodo = useCallback((text) => {
    dispatch({ type: 'ADD_TODO', text });
  }, []);
  
  const toggleTodo = useCallback((id) => {
    dispatch({ type: 'TOGGLE_TODO', id });
  }, []);
  
  return (
    
      
      
    
  );
}

Since the dispatch function from useReducer is stable, our callbacks can have empty dependency arrays.

2. Using Refs with useCallback

Sometimes you need the latest state value in a callback without recreating it:

function Timer() {
  const [count, setCount] = useState(0);
  const countRef = useRef(count);
  
  // Update ref when count changes
  useEffect(() => {
    countRef.current = count;
  }, [count]);
  
  const startTimer = useCallback(() => {
    const intervalId = setInterval(() => {
      // Use ref to get the latest count value
      setCount(countRef.current + 1);
    }, 1000);
    
    return () => clearInterval(intervalId);
  }, []); // Empty dependencies because we're using ref
  
  return (
    
      Count: {count}
      Start
    
  );
}

Performance Considerations and Best Practices

  1. Profile before optimizing: Use React DevTools to identify actual performance bottlenecks before adding useCallback everywhere.

  2. Keep dependency arrays honest: Always include all values that your function uses from the component scope.

  3. Consider the cost: useCallback adds memory overhead, so use it judiciously.

  4. Use the functional update pattern when possible to avoid dependencies:

// Instead of:
const increment = useCallback(() => {
  setCount(count + 1);
}, [count]); // Dependency on count

// Prefer:
const increment = useCallback(() => {
  setCount(c => c + 1);
}, []); // No dependencies needed

Common Pitfalls and How to Avoid Them

1. Stale Closures

function Counter() {
  const [count, setCount] = useState(0);
  
  const increment = useCallback(() => {
    // This will always log 0 because it captures
    // the initial count value
    console.log(count);
    setCount(count + 1);
  }, []); // Missing count dependency
  
  return Count: {count};
}

Solution: Either add the dependency or use the functional update pattern.

2. Incorrect Dependency Arrays

function Component({ id, name }) {
  const [data, setData] = useState(null);
  
  const fetchData = useCallback(() => {
    fetch(`/api/${id}/${name}`)
      .then(response => response.json())
      .then(setData);
  }, [id]); // Missing name dependency!
  
  useEffect(() => {
    fetchData();
  }, [fetchData]);
  
  return {data};
}

Solution: Include all values used inside the callback in the dependency array.

Real-World Example: Optimizing a Data Table

Let's look at a practical example of optimizing a data table component:

import React, { useState, useCallback } from 'react';

// Optimized row component
const TableRow = React.memo(({ row, onEdit, onDelete }) => {
  return (
    
      {row.name}
      {row.email}
      
         onEdit(row.id)}>Edit
         onDelete(row.id)}>Delete
      
    
  );
});

function DataTable({ data }) {
  const [sortBy, setSortBy] = useState('name');
  
  // Memoized handlers
  const handleEdit = useCallback((id) => {
    console.log('Edit item:', id);
    // Edit logic here
  }, []);
  
  const handleDelete = useCallback((id) => {
    console.log('Delete item:', id);
    // Delete logic here
  }, []);
  
  const handleSort = useCallback((column) => {
    setSortBy(column);
  }, []);
  
  return (
    
        {data.map(row => (
          
        ))}
      
      
      
    
  );
}

In this example, TableRow components won't re-render when the parent component updates for reasons unrelated to the row data or action handlers.

Conclusion

The useCallback hook is a powerful tool in the React performance optimization toolkit, but it's not a silver bullet. Use it strategically when:

  1. Passing functions to optimized child components wrapped in React.memo
  2. Functions are dependencies of other Hooks like useEffect
  3. Creating custom hooks that return stable function references

Remember that premature optimization can be counterproductive. Always measure performance first, then apply useCallback where it will have the most impact.

By understanding when and how to use useCallback effectively, you can create React applications that are both performant and maintainable.

Further Reading

Share:

Scroll to top control (visible after scrolling)