Happy 2021! đ
Back in October, I gave four options for dealing with helper functions listed as dependencies in the React useEffect
Hook. I promised to share other gotchas around useEffect
dependencies in follow-up posts. So now I want to give four more options, but this time dealing with objects or arrays in dependencies.
Letâs quickly recap the useEffect
Hook in React. By default it runs on every re-render:
const Example = () => {
const [count, setCount] = useState(0)
useEffect(() => { document.title = `You clicked ${count} times` })
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>Click me</button>
</div>
)
}
However, we rarely want this functionality because itâs likely that the effect will run more times than it really needs to. The useEffect
hook takes a second parameter, a âdependenciesâ array, that will only re-run the effect when the values within the array change across re-renders. This allows us to optimize how many times the effect is run.
const Example = () => {
const [count, setCount] = useState(0)
useEffect(() => {
document.title = `You clicked ${count} times`
}, [count]) // Only re-run the effect if count changes
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>Click me</button>
</div>
)
}
This works perfectly fine when weâre using primitive values like booleans, numbers, and strings. But complex values like objects, arrays, and functions pose a challenge because React uses âreferential equalityâ for these complex values within the dependencies array.
React checks to see if the object in the current render points to the same object in the previous render. The objects have to be the exact same object in order for useEffect
to skip running the effect. So even if the contents are the exact same, if a new object is created for the subsequent render, useEffect
will re-run the effect.
Iâve already discussed dealing with helper functions in the previous post, so in this post I want to focus on options we have when dealing with objects or arrays as useEffect
dependencies.
Option 1 - Depend on the pieces
In this first problem, letâs say we have an object being passed in as a prop that weâre also using in the dependencies list of useEffect
.
import React, { useState, useEffect } from 'react'
import { getPlayers } from '../api'
import Players from '../components/Players'
const Team = ({ team }) => {
const [players, setPlayers] = useState([])
useEffect(() => {
if (team.active) { getPlayers(team.id).then(setPlayers) }
}, [team])
return <Players team={team} players={players} />
}
Ideally if the contents of the team
prop are the same, the object reference would be the same, but unfortunately there arenât any guarantees.
However, in this flavor of the problem, weâre only using a couple of properties on the team
object. And they are properties that are primitives. So instead of having useEffect
depend on the whole team
object, it should only depend on the properties used within the effect.
import React, { useState, useEffect } from 'react'
import { getPlayers } from '../api'
import Players from '../components/Players'
const Team = ({ team }) => {
const [players, setPlayers] = useState([])
useEffect(() => {
if (team.active) {
getPlayers(team.id).then(setPlayers)
}
}, [team.id, team.active])
return <Players team={team} players={players} />
}
Assuming that the id
and active
properties of the team
object are primitives, the effect will only be run when the id
value or the active
state changes. The nice part is that other data within the team
object could change, but the effect will not be re-run unless the id
value or the active
state change.
This also would work if the team
object was created within the component:
import React, { useState, useEffect } from 'react'
import { getPlayers } from '../api'
import Players from '../components/Players'
const Team = ({ id, name, active }) => {
// construct the object from props and/or state
const team = { id, name, active } const [players, setPlayers] = useState([])
useEffect(() => {
if (team.active) {
getPlayers(team.id).then(setPlayers)
}
}, [team.id, team.active])
return <Players team={team} players={players} />
}
Even though the team
object is being recreated with every render of the Team
component (which weâll see in Option 2 can be a problem), itâs not a problem here because again, useEffect
is only depending on the id
value and active
state.
This option of depending on the pieces likely wonât be useful with arrays, as weâre likely not going to use specific indices of an array with
useEffect
.
Option 2 - Recreate the object within
So what happens if we take the last example, but instead of the effect using a couple of properties of the object, it needs the whole object (or array)?
import React, { useState, useEffect } from 'react'
import { getPlayers } from '../api'
import Players from '../components/Players'
const Team = ({ id, name, active }) => {
// construct the object from props/state
const team = { id, name, active } const [players, setPlayers] = useState([])
useEffect(() => {
if (team.active) {
getPlayers(team).then(setPlayers) }
}, [team])
return <Players team={team} players={players} />
}
Now, getPlayers
takes the whole team
object instead of just team.id
. And the team
object is being recreated for every render of the Team
component. So even if the id
, name
, and active
props are the same values, because the actual object is new with each render, useEffect
will run the effect every time.
Luckily for us, the react-hooks/exhaustive-deps
ESLint rule (part of eslint-plugin-react-hooks
) complains saying:
The 'team' object makes the dependencies of useEffect Hook
change on every render. To fix this, wrap the initialization
of 'team' in its own useMemo() Hook.
By the way, if you donât have
eslint-plugin-react-hooks
as part of your ESLint setup, itâs a must for developing with Hooks. It will flag so many gotchas for you.
Before we look at ESLintâs suggestion of using the useMemo
Hook (thatâs Option 3), I want to try something potentially simpler. We can create the team
object twice; one to pass to <Players />
in the UI and one to use within useEffect
:
import React, { useState, useEffect } from 'react'
import { getPlayers } from '../api'
import Players from '../components/Players'
const Team = ({ id, name, active }) => {
const [players, setPlayers] = useState([])
useEffect(() => {
// recreate the `team` object within `useEffect`
// from props/state
const team = { id, name, active }
if (team.active) {
getPlayers(team).then(setPlayers)
}
}, [id, name, active])
const team = { id, name, active }
return <Players team={team} players={players} />
}
Now that the team
object is created within useEffect
, itâll only be created when the effect is run. And now that id
, name
, and active
are specified as dependencies (which are primitive values), the effect will only run when their values change.
Creating object literals is cheap, so it should be okay to duplicate the team
object within useEffect
. The performance improvements weâre gaining from optimizing useEffect
far outweigh creating the two objects.
Option 3 - Memoize the object
However, if creating the object or array is expensive, then having to create it twice may actually be worse than running the effect too many times. In this case, we need to âcacheâ the object or array weâve created so that itâll persist across renders when its data doesnât change. This process is called âmemoizationâ and we can use the useMemo
Hook to accomplish this.
import React, { useState, useEffect, useMemo } from 'react'
import { createTeam } from '../utils'
import { getPlayers } from '../api'
import Players from '../components/Players'
const Team = ({ id, name, active }) => {
// memoize calling `createTeam` because it's
// an expensive operation
const team = useMemo(() => createTeam({ id, name, active }), [ id, name, active, ]) const [players, setPlayers] = useState([])
useEffect(() => {
if (team.active) {
getPlayers(team).then(setPlayers)
}
}, [team])
return <Players team={team} players={players} />
}
Assuming that the createTeam()
helper is expensive, we want to call it as few times as possible. The useMemo
Hook will only recreate the team
object if either id
, name
or active
change across renders. But if none of them change when Team
is re-rendered, the team
object is the exact same object. And because itâs the same object we can safely use it within useEffect
without running the effect too many times.
Option 4 - Do it yourself
What happens when weâre in the worst of both worlds? This is the situation where the object or array is passed in as a prop so we cannot trust its referential equality, and the effect needs to use the entire object.
import React, { useState, useEffect } from 'react'
import { createTeam } from '../utils'
import { getPlayers } from '../api'
import Players from '../components/Players'
const Team = ({ team }) => { const [players, setPlayers] = useState([])
useEffect(() => {
if (team.active) {
getPlayers(team).then(setPlayers) }
}, [team])
return <Players team={team} players={players} />
}
The unfortunate part of this situation is that we get no warning from the react-hooks/exhaustive-deps
ESLint rule. It doesnât know for sure that using team
in the dependencies list is going to be bad because it doesnât know about its referential equality.
We cannot use useMemo
from Option 3 because we donât know for certain what are the dependencies to create the team
object. Itâs passed in. But we still want to accomplish the same goal of âcachingâ the objectâs value across renders.
Instead of using the useMemo
Hook, we can (ab)use the useRef
Hook.
import React, { useState, useEffect, useRef } from 'react'
import isDeepEqual from 'fast-deep-equal/react'import { getPlayers } from '../api'
import Players from '../components/Players'
const Team = ({ team }) => {
const [players, setPlayers] = useState([])
const teamRef = useRef(team)
if (!isDeepEqual(teamRef.current, team)) { teamRef.current = team }
useEffect(() => {
if (team.active) {
getPlayers(team).then(setPlayers)
}
}, [teamRef.current])
return <Players team={team} players={players} />
}
The mutable teamRef
maintains its state across renders of the Team
component. Then using fast-deep-equal
, we check to see if the new team
prop deep equals the one maintained in the teamRef
. Deep equality differs from referential equality in that it will go down the tree of the object (or array) and compare each value. So even if the team
prop is a new object for every render of Team
, if the contents are the same, isDeepEqual()
will be true
.
So when they do deep equal, teamRef.current
will continue to point to the original team
prop reference. And since the dependency references are the same, useEffect
will not re-run the effect. đ€Ż
When isDeepEqual
returns false
, the new team
prop is set as the current value of the teamRef
. This will trigger useEffect
to re-run the effect since the dependency reference has now changed.
The problem with this approach is that it totally confuses the react-hooks/exhaustive-deps
ESLint rule because we are pretty much abusing it:
React Hook useEffect has a missing dependency: 'team'.
Either include it or remove the dependency array.
Mutable values like 'teamRef.current' aren't valid
dependencies because mutating them doesn't re-render
the component.
We obviously donât want to use team
as the dependency because thatâs the error weâre trying to avoid. If we swap in teamRef.current
instead of team
within the effect code:
React Hook useEffect has an unnecessary dependency:
'teamRef.current'. Either exclude it or remove the
dependency array. Mutable values like 'teamRef.current'
aren't valid dependencies because mutating them
doesn't re-render the component.
The lint rule stops complaining if the deps list is empty with the code using teamRef.current
, but then itâll never re-run! Thatâs now too optimized! I try to avoid disabling the react-hooks/exhaustive-deps
lint rule because itâs really trying to save us. But it doesnât expect refs to be used quite like how weâre using it. đ
Honestly, if you find yourself in this situation, I would suggest reaching for the useDeepCompareEffect
custom hook from the react-use
package of essential hooks. Itâs underlying implementation also uses refs, but since itâs a custom Hook, the react-hooks/exhaustive-deps
lint rule isnât triggered. đ
The majority of the time, I can solve the problem with Option 1. In fact, once I ran into this problem the first couple of times, I just implement Option 1 from the start. But when depending on the pieces doesnât work, then Iâll just use a helper Hook from react-use
.
As with everything React-related, there are always many ways to solve a problem. These are the four that I know of, but Iâm curious if youâve found other ways to solve this. Iâm also curious if this has even been a problem for you. đ Either way, feel free to reach out to me on Twitter at @benmvp to let me know!
Keep learning my friends. đ€