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.
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
.
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.
const memoizedFunction = useCallback(() => {
// Your logic here
}, [dependency1, dependency2, ...]);
The useCallback()
hook takes two arguments:
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.
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:
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:
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.
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.
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:
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.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.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:
memo
To avoid some unnecessary errors when using useCallback, here are some key best practices to consider:
React.memo()
, use useCallback to keep the function reference stable and avoid unnecessary rerenders.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:
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.
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 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.