- Introduction
- The problem: Re-render loops without useCallback
- The solution: Prevent re-render loops with useCallback
- Conclusion
Introduction
In React, custom Hooks are a powerful way to encapsulate logic and make it reusable throughout different components. However, if not used carefully, custom Hooks can cause unintended re-render loops — situations where components keep re-rendering unnecessarily, leading to performance degradation or even crashing the application. In this blog post, we’ll explore how this problem can occur, particularly in custom Hooks, and how useCallback
can prevent it.
The problem: Re-render loops without useCallback
Re-render loops occur when components keep re-rendering unnecessarily because one ore more dependencies are changing on every render. Common causes of such issues include passing functions down as props or using them as dependencies in useEffect
, useMemo
, or similar Hooks. In React, whenever a component re-renders, functions inside the component are re-created. If these functions are used as dependencies in other hooks (e.g. useEffect
), it can initiate a re-render loop. This can result in a stalled and unusable application.
Example without useCallback
As mentioned before, custom Hooks in particular can cause this problem. Here is an example where a re-render loop occurs because the log
function, returned from useLogger
, is not wrapped in useCallback
:
import { useEffect, useState } from 'react';
const useLogger = () => {
const [logCount, setLogCount] = useState(0);
const log = () => {
console.log('Logging...');
setLogCount(logCount => logCount + 1);
};
return [logCount, log];
}
const App = () => {
const [count, setCount] = useState(0);
const [logCount, log] = useLogger();
useEffect(() => {
if (count > 5) {
log();
}
}, [count, log])
return (
<div>
<button onClick={() => setCount(count => count + 1)}>
Increment (count is {count})
</button>
<div>{logCount} logs generated</div>
</div>
)
}
export default App
Explanation
In the example above, we have a custom hook useLogger
that returns:
- A
logCount
state, indicating how many logs were produced in total - A
log
function that logs to the browser console
The App
is a simple component demonstrating the use of the useLogger
Hook. It maintains a count
state that increments each time a button is clicked. When count
increases, the useEffect
executes and checks if count
is greater than 5. If this is the case, the log
function is called. The total number of logs generated is also displayed.
Here is the problem: The log
function gets re-created every time App
re-renders. As the useEffect
depends on log
, this triggers the effect on every render. If count
is greater than 5, this creates a loop where the component continually re-renders, causing unnecessary execution of the effect and potentially leading to performance issues. The following warning is displayed in the browser console:
Warning: Maximum update depth exceeded. This can happen when a component calls setState inside useEffect, but useEffect either doesn't have a dependency array, or one of the dependencies changes on every render.
The solution: Prevent re-render loops with useCallback
To solve this issue, we can use the useCallback
Hook. useCallback
allows us to memoize a function so that it only changes when its dependencies change. This prevents the creation on every render and thus stops the loop.
Example with useCallback
Here is the adjusted example with useCallback
:
import { useCallback, useEffect, useState } from 'react';
const useLogger = () => {
const [logCount, setLogCount] = useState(0);
const log = useCallback(() => {
console.log('Logging...');
setLogCount(logCount => logCount + 1);
}, []);
return [logCount, log];
}
const App = () => {
const [count, setCount] = useState(0);
const [logCount, log] = useLogger();
useEffect(() => {
if (count > 5) {
log();
}
}, [count, log])
return (
<div>
<button onClick={() => setCount(count => count + 1)}>
Increment (count is {count})
</button>
<div>{logCount} logs generated</div>
</div>
)
}
export default App
Explanation
The log
function is now wrapped with useCallback
, and we have provided an empty dependency array. Therefore, the function will only be created once when the component first renders.
Conclusion
Re-render loops can be a subtle yet significant problem in React applications, particularly when working with custom Hooks. Not properly memoizing functions with useCallback can cause your components to re-render more often than needed, leading to performance issues or even application crashes. The solution is to wrap your custom Hook functions in useCallback
to ensure they are only re-created when necessary. Keep this in mind the next time you are writing custom Hooks to keep your components running smoothly.