Telerik blogs

Learn how callback functions work in React and how to use the useCallback hook effectively in your React application.

Optimizing for performance is essential for delivering a smooth user experience in any web application. As your application grows in complexity, the need for efficient rendering and responsiveness naturally becomes more important. In React, memoization is one of the core techniques for enhancing performance in your application—used to prevent unnecessary component rerenders and recalculation of expensive values or functions by caching the results of costly function calls and returning the cached output when the same inputs occur again.

One of the built-in APIs provided for memoization in React is the useCallback() hook. This hook lets you cache a function definition between rerenders. In this article, we’ll break down how callback functions work in React, why they matter, and how to use useCallback() effectively in your React application.

How Callback Functions Work in React

Before exploring the useCallback() hook, it is essential first to grasp the concepts of callback functions and function reference, and the significance of stable function references in React. In JavaScript, a callback function is a function passed as an argument to another function, intended to be executed after a specific event occurs or once an operation completes.

In React, callback functions work similarly to how they do in JavaScript, but they’re often used as props passed down to child components. This mechanism facilitates communication from child components to parent components, enabling a flow of data and actions up the component tree. For example, as shown in the code sample below, you can define a button onClick event handler as a callback function that updates the component’s state or performs an action when the button is clicked:

import { useState } from "react";

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

 const handleClick = () => {
   setCount(count + 1);
   console.log("Clicked");
 };

 return (
   <>
     <h2>{count}</h2>
     <button onClick={handleClick}>Click Me</button>
   </>
 );
}

The code snippet above demonstrates the use of a callback function, handleClick, within a React component. The function gets triggered when the associated button is clicked, resulting in an increment of the count state. While callback functions are valuable in React for handling events and interactions, they can lead to performance bottlenecks if not implemented correctly. This usually happens due to how React’s rendering mechanism works and the way JavaScript treats functions as objects.

Each time you declare a function inside a component, it creates a new function instance with a different reference in memory. This means that even if the function’s logic remains the same, React treats it as a new entity every time.

In the code snippet above, every time the parent component rerenders or the count state updates, React recreates the handleClick function. React sees the function as different from the last one because it was recreated during the new render. Even if the code inside is the same, React compares function references, and a new function means a new reference.

This behavior can lead to unnecessary rerendering, especially when you pass a callback function as a prop to a memoized child component (React.memo). When the function reference changes, any memoized child component receiving it as a prop will rerender even if the function behavior has not changed. For example:

import React, { useState} from "react";

export default function ParentComponent() {
 const [count, setCount] = useState(0);

 const handleClick = () => {
   setCount(count + 1);
   console.log("Clicked");
 };

 return (
   <>
     <h2>{count}</h2>
     <ChildComponent onClick={handleClick} />
   </>
 );
}

const ChildComponent = React.memo(({ onClick }) => {
 console.log("child component rendered");
 return <button onClick={onClick}>Click Me</button>;
});

If you open the console, you’ll notice that ChildComponent rerenders every time ParentComponent does. This happens because the handleClick function isn’t wrapped in useCallback, so a brand-new function is created on every render. Since React compares props by reference, it sees the updated onClick prop as different, even if the function’s logic hasn’t changed and triggers a rerender of ChildComponent.

What Is useCallback?

The useCallback() hook is one of React’s built-in performance optimization tools. Its primary purpose is to memoize/cache function definition between rerenders, meaning it prevents the recreation of a function on every rerender of a component unless its dependencies change.

Syntax and Parameters

const memoizedFunction = useCallback(() => {
 // Your logic here
}, [dependency1, dependency2, ...]);

The useCallback() hook takes two arguments:

  • A function to memoize: This is the first argument, and it represents the function you want to memoize/cache. It can accept any arguments and return any values.
  • Dependency array: This is the second argument, which contains all the reactive values referenced inside the callback function code. These values can include props, state and any variables or functions declared directly inside your component body. The dependency array determines when React should recreate the callback function. React will only recreate the function if any of the values in the array changes between renders.

When you call useCallback(), React returns a memoized version of your function. On the first render, it creates and returns the function. On later renders, if none of the dependencies have changed, it reuses the previous function. If any dependency has changed, React creates a new one. This means it reuses the same function instance across renders—as long as its dependencies stay the same.

Since useCallback() is a React Hook, you must follow the rules of hooks: You can only call it at the top level of a React function component or a custom hook, not inside loops, conditions or nested functions. If you need to do that, you’ll have to extract a new component and move the state logic into it.

Practical Example for useCallback

Skipping Rerendering of Components

You can either create a new React project on your computer or make use of codesandbox to follow along. Let’s consider the typical to-do app. This app displays a list of todos and includes a feature to mark each todo as done or undone.

Create a Todo.js component and add the following code:

import React, { useState} from "react";

const TodoItem = React.memo(({ todo, onChange }) => {
 console.log(`Rendering ${todo.name} :(`);
 return (
   <div>
     <span style={{ textDecoration: todo.done ? "line-through" : "none" }}>
       {todo.name}
     </span>
     <button onClick={() => onChange(todo.id)}>
       {todo.done ? "Undone" : "Done"}
     </button>
   </div>
 );
});

const demoTodos = [
 { id: 0, name: "Todo 1", done: false },
 { id: 1, name: "Todo 2", done: false },
 { id: 2, name: "Todo 3", done: false },
 { id: 3, name: "Todo 4", done: false },
];

const TodoList = () => {
 const [todos, setTodos] = useState(demoTodos);

 function toggleTodo(id) {
   setTodos((prevTodos) =>
     prevTodos.map((todo) =>
       todo.id === id ? { ...todo, done: !todo.done } : todo
     )
   );
 }

 return (
   <div>
     <h2>Today Todo</h2>
     <ul>
       {todos.map((todo) => (
         <li key={todo.id}>
           <ToodItem todo={todo} onChange={toggleTodo} />
         </li>
       ))}
     </ul>
   </div>
 );
};

export default TodoList;

The code snippets above render a TodoList parent component that displays the list of todos. The memoized child component, TodoItem, receives a callback function as a prop to toggle a todo as done or undone. When you click on the toggle button and the state changes, React will rerender the TodoItem component even though it is wrapped in a memo to prevent unnecessary rerendering, as shown below:

When user marks a todo item done, the task name is crossed out

You will notice that when you click the button, the entire TodoItem component rerenders for all todos. This occurs because each time the TodoList component rerenders, a new function reference is created for the toggleTodo callback. As a result, since the props are now different, React triggers a rerender of the TodoItem component.

Now, imagine having hundreds of todos in the list, these unnecessary rerenders could severely drag down your app’s performance. If you click the button repeatedly, it might even lead to performance issues or potential crashes.

To fix this, you can wrap the toggleTodo callback function with the useCallback() hook. This stabilizes the function reference and prevents unnecessary rerenders:

const toggleTodo = useCallback((id) => {
 setTodos((prevTodos) =>
   prevTodos.map((todo) =>
     todo.id === id ? { ...todo, done: !todo.done } : todo
   )
 );
}, []);

The toggleTodo function uses the functional update pattern by accessing prevTodo, which represents the state before the update so that it always works with the latest state. Since the function doesn’t rely on any reactive values, it uses an empty dependency array ([]), informing React to create the toggleTodo function only during the initial render and keep it across subsequent renders. React will only recreate the function if the parent component rerenders or unmounts. This optimization technique helps prevent unnecessary recreations of the toggleTodo function.

Using useCallback(), the TodoItem component rerenders only the specific todo whose status changes, significantly improving performance:

When user marks a todo item done, the task name is crossed out. The processing shows less rerendering

Here is the link to the CodeSandbox for this demo. You can add more features like delete, add and edit to the todo demo app to practice how to use useCallback for skipping unnecessary rerendering.

Optimizing a Custom Hook with useCallback

When working with a custom hook, it is recommended to wrap any function it returns in useCallback to maintain stable references. Take a look at the useTodo custom hook below:

function useTodo() {

   const [todos, setTodos] = useState([]);

   const addTodo = useCallback((todo) => {
     setTodos((todos) => [...todos, todo]);
   }, []);

   const deleteTodo = useCallback((todoId) => {
     setTodos(todos.filter((t) => t.id !== todoId));
   }, []);

  return { todos, addTodo, deleteTodo };

}

This approach means consumers of your hook can optimize their own code when needed.

When to Use and Avoid useCallback() Hook

To make the most of the useCallback hook in your React projects, you need to understand where it adds value and where it does not. Here are four key scenarios in which useCallback can be beneficial:

  • Passing callback to child components: When you pass a callback down to a child component, especially one wrapped in React.memo(), React might rerender the child unnecessarily every time the parent component updates, even if the function logic has not changed. Wrapping the callback in useCallback ensures the function reference stays stable unless its dependencies change, helping to avoid unnecessary rerenders.
  • When avoiding stale closures (with care): When a callback function relies on props or state values, using useCallback means that React only recreates the function when necessary. This helps prevent bugs associated with outdated references, but it’s crucial to maintain an accurate dependency array. Misusing useCallback can lead to stale closures, so it’s essential to use it carefully.
  • Working with custom hooks: It is recommended to use useCallback to maintain stable function references when creating a custom hook that returns callback functions, especially if those functions will be used as dependencies in other effects.
  • Using a callback inside useEffect(): Sometimes you might want to call a function from the useEffect hook, which might lead to a problem if the callback function is a dependency. Wrapping the callback function in useCallback means React only reruns the effect when necessary, avoiding excessive or unintended effect executions.

While the useCallback hook is an important performance optimization technique, there are scenarios where wrapping callback functions with useCallback adds unnecessary complexity without real benefit:

  • When you’re building simple or less interactive components that don’t rerender often
  • When you’re not passing a callback as a prop to a component wrapped in memo
  • When memoizing a function doesn’t bring any tangible performance improvement

Best Practices for React useCallback

To avoid some unnecessary errors when using useCallback, here are some key best practices to consider:

  • Always call useCallback at the top level: Like all React hooks, useCallback must be called at the top level of your function component, not inside loops, conditions or nested functions.
  • Pair it with memoized child components when needed: If you’re passing a callback to a child component wrapped in React.memo(), use useCallback to keep the function reference stable and avoid unnecessary rerenders.
  • Manage dependencies carefully: The dependency array is critical. Omitting dependencies can lead to bugs or stale values. Always include all the props, state or variables your callback relies on.
  • Don’t treat useCallback as a fix for broken logic: Only rely on useCallback as a performance optimization. If your code breaks without it, fix the underlying issue before relying on useCallback.

While memoization is a useful concept in React, the best way to optimize your app is to write components in a way that often makes memoization unnecessary. Here are some principles to help you achieve that:

  • Use children props effectively: When creating wrapper components, allow them to receive JSX via children. This keeps updates to the wrapper’s internal state from causing unnecessary rerenders of its children.
  • Keep state local when possible: Keep your component state localized and avoid lifting the state up more than necessary. Do not keep transient states, such as forms or hover interactions, at the top of your component tree or in a global state library.
  • Keep rendering logic pure: Always keep your rendering logic pure. If rerendering a component causes a problem or produces some noticeable performance error, there’s a good chance it’s a bug in the component itself. Focus on fixing that bug first before considering useCallback.
  • Minimize unnecessary useEffect chains: Minimize unnecessary effects that trigger state updates. Many performance bottlenecks in React apps are caused by excessive chains of updates originating from Effects that cause your components to render repeatedly.
  • Simplify instead of over-memoizing: Aim to remove unnecessary dependencies from your Effects. For instance, instead of relying on memoization, consider simplifying your code by moving some objects or functions within an Effect or outside the component.

By following these best practices, you’ll reduce over-reliance on useCallback and make your components more predictable, maintainable and performant. They also help you identify where memoization will truly add value, so it’s always a good idea to apply them.

Conclusion

I hope you enjoyed reading this article. We explored how the useCallback() hook works, why it matters and how to use it effectively in your application. useCallback is pretty straightforward and exists purely to make our lives a bit easier when trying to memoize function definitions between rerenders, but you must pay close attention to where and when to use it. You don’t need to wrap a function in useCallback unless you have a good reason to. In this example, we used it because we’re passing a callback to a component wrapped in a memo.

When you understand how React handles function references and recognize useCallback as a powerful tool for keeping those references stable, you can actively prevent unnecessary rerenders and boost your application’s performance.


david-adeneye-sq
About the Author

David Adeneye Abiodun

David Adeneye is a software developer and a technical writer passionate about making the web accessible for everyone. When he is not writing code or creating technical content, he spends time researching about how to design and develop good software products.

Related Posts

Comments

Comments are disabled in preview mode.