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.
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]);
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:
A component is mounted
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 theuseEffect
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]);
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']);
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']);
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]);
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(() => ({}, []);
Move emptyObject
outside of the component code:
const emptyObject = {}
function Superheroes() {
// ...
}
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
]);
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]);
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
});
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 tosetState
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!
Top comments (3)
Awesome overview of o problems in useEffects & dependency array
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
Great article!