Avoid re-render loops in React: The importance of wrapping custom Hook functions with useCallback

René Kulik on 19.08.2024

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:

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.