DEV Community

Cover image for React: Lessons from the Trenches - useEffect x Infinity
Peter Shershov for Codux

Posted on

React: Lessons from the Trenches - useEffect x Infinity

Hey! My name is Peter and I’m a software team-lead, working on Codux by Wix.com.

I’ve been building user interfaces with React for more than 6 years and have made tons of mistakes during this time. From memoizing almost everything in my component to drilling props like I’m looking for oil — I fell into every trap. I’ll try to break down a couple of issues I’ve dealt with and hopefully, it’ll help you jump over pitfalls in the future. Some articles in this series will address bad practices and design concerns while others focus on specific bugs in special use cases.

Let’s start with a prevalent one, updating a state within useEffect can cause an infinite loop by re-rendering the component to infinity.

 raw `Maximum update depth exceeded` endraw  warning

What we see above is a warning (“Maximum update depth exceeded”) indicating that there’s an operation occurring too many times, one after the other.

This can happen in several scenarios.

const [superheroes, setSuperheroes] = useState([]);

const updateSuperheroes = () => {
   const newSuperhero = generateSuperhero();
   setSuperheroes([...superheroes, newSuperhero]);
};

useEffect(() => {
  updateSuperheroes();
}, [updateSuperheroes]);
Enter fullscreen mode Exit fullscreen mode

Consider the example above.
First of all, we know that every state update triggers a re-render, meaning that the code written inside the component function gets executed. We also know that useEffect executes when one of two conditions is met after a render:

  1. A component is mounted

  2. One of the dependencies (declared in useEffect’s dependency array) has changed.

Keep in mind that useEffect’s dependency array should reflect the usage. Omitting a dependency that is used inside the useEffect can cause stale data issues, while including a dependency that does not impact the effect can cause unnecessary re-renders.

With these in mind, we can start debugging the code above.
We can see that updateSuperheroes() is executed inside a useEffect, which requires us to declare it as a dependency.
updateSuperheroes adds superheroes to the state by creating a copy of the superheroes array and adding new entries to it.

When the superheroes state gets updated, the component re-renders with the new state, the updateSuperheroes function is created anew which causes useEffect to trigger, restarting this cycle.

What to do?

To prevent something from being created multiple times over multiple renders we often take advantage of memoization. React offers several tools to memoize our functions and values. useCallback is used for functions and useMemo is used for values such as objects or arrays.

Primitives that require a minimal computation don't need to be memoized since they are easily comparable, on the other hand, objects, functions, arrays and other complex data structures, could be created anew with every render if declared inside the component.

In our case, updateSuperheroes is a function, and should be memoized, therefore, we should leverage useCallback.

const [superheroes, setSuperheroes] = useState([]);

const updateSuperheroes = useCallback(() => {
   const newSuperhero = generateSuperhero();
   setSuperheroes([...superheroes, newSuperhero]);
}, [superheroes]);

useEffect(() => {
  updateSuperheroes();
}, [updateSuperheroes]);
Enter fullscreen mode Exit fullscreen mode

But this is not enough. We can see that useCallback has a dependency on superheroes that gets changed on every run of useEffect, making this memoization redundant. For such cases, React provides us with another way to update the state without being dependent on the value.

Every state setter can get two types of the first argument.

One, a new state:

const [state, setState] = useState([])
// ...
setState([...state, 'something new']);
Enter fullscreen mode Exit fullscreen mode

Or two, a function that gets the previous state as an argument and returns a new state.

const [state, setState] = useState([])
// ...
setState(previousState => [...previousState, 'something new']);
Enter fullscreen mode Exit fullscreen mode

Using the latter way in our example will fix the infinite loop since there is no dependency on the state anymore. It will trigger only when the component mounts.

const [superheroes, setSuperheroes] = useState([]);

const updateSuperheroes = useCallback(() => {
   const newSuperhero = generateSuperhero();
   setSuperheroes((previousSuperheroes) => [
      ...previousSuperheroes,
      newSuperhero
   ]);
}, []);

useEffect(() => {
  updateSuperheroes();
}, [updateSuperheroes]);
Enter fullscreen mode Exit fullscreen mode

In some cases, you can extract values to an outer scope (outside of the component) when a value is not dependent on any specific component code.

For example, instead of:

const emptyObject = useMemo(() => ({}, []);
Enter fullscreen mode Exit fullscreen mode

Move emptyObject outside of the component code:

const emptyObject = {}

function Superheroes() {
// ...
}
Enter fullscreen mode Exit fullscreen mode

This will prevent the creation of a new object instance for every render without the need to memoize it over time.

What can be so complex?

Now that we know how to handle the simple case of a state update within a useEffect, let’s delve into the more complex scenarios.

In the example above we could very easily tell which dependency in our useEffect is causing the infinite loop. Debugging more complex cases where useEffect has a large number of dependencies (and each one of them might have its own set of dependencies) is a bit harder. Finding the culprit and maintaining this code in general requires more effort.

I’ve seen colossal dependency arrays across many projects, and here are a couple of recommendations for how to deal with them.

1. Separate your gigantic useEffect into a few smaller ones

useEffect(() => {
   // ...
}, [
   peopleProvider,
   engineersProvider,
   doctorsProvider,
   generateUniqueId,
   initializeApp
]);
Enter fullscreen mode Exit fullscreen mode

Converting this example, where a useEffect gets a large number of possibly non-related dependencies, into this:

useEffect(() => {
   // ...
}, [peopleProvider]);

useEffect(() => {
   // ...
}, [engineersProvider]);

useEffect(() => {
   // ...
}, [doctorsProvider]);

useEffect(() => {
   // ...
}, [generateUniqueId, initializeApp]);
Enter fullscreen mode Exit fullscreen mode

This way we can scope our side effects into separate, smaller useEffect declarations to prevent triggering irrelevant handlers.
In other words, updated dependencies will not trigger a side effect or a state update for a non-relevant functionality.

2. Debug the dependencies that are changing on every render

If you still couldn’t find the main cause of an infinite render and you still don’t know which state gets updated and what handler is causing it, I recommend implementing or using a helper hook (such as this) that logs the changed dependencies for each render.

useEffect(() => {
   // ...
}, [
   peopleProvider,
   engineersProvider,
   doctorsProvider,
   generateUniqueId,
   initializeApp
]);

useTraceUpdate({
   peopleProvider,
   engineersProvider,
   doctorsProvider,
   generateUniqueId,
   initializeApp
});
Enter fullscreen mode Exit fullscreen mode

This will log only the changed values, indicating which dependency is changing every render leading to an infinite loop.

Conclusion

Fixing and avoiding useEffect’s infinite renders can be achieved by applying the following measures:

  • Keep your dependency arrays short and concise. Scope the useEffect you’re creating to a specific minimal context and avoid mixing dependencies that have different intents.
  • Memoize complex values and functions passed to your hooks and components.
  • Avoid depending on a state and setting it in the same useEffect, this is a very fragile situation that can break easily. If you must have such a case, you can take advantage of the option to set the state using a function provided to setState and not be dependent on the previous value.
  • Find the always-changing dependency (the one that is causing the infinite render) by debugging your dependencies. Try locating the value that is causing the infinite renders and memoize it correctly.

I hope you've found this article helpful, and I look forward to see y'all at the next one!

Latest comments (3)

Collapse
 
dowenrei profile image
wenrei

Great article!

Collapse
 
prathameshkdukare profile image
Prathamesh Dukare

Awesome overview of o problems in useEffects & dependency array

Collapse
 
zohaib546 profile image
Zohaib Ashraf

A great effort
Clears many use cases and shows a way to eliminate infinite loops
Im looking solution like this from a long time
thanks keep going