Skip Main Navigation
Ben IlegboduBen Ilegbodu

Object & array dependencies in the React useEffect Hook

Four options for optimizing the useEffect Hook with object or array dependencies in React

Sunday, January 03, 2021 · 7 min read

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. 🤓

Subscribe to the Newsletter

Get notified about new blog posts, minishops & other goodies


Hi, I'm Ben Ilegbodu. 👋🏾

I'm a Christian, husband, and father of 3, with 15+ years of professional experience developing user interfaces for the Web. I'm a Google Developer Expert Frontend Architect at Stitch Fix, and frontend development teacher. I love helping developers level up their frontend skills.

Discuss on Twitter // Edit on GitHub