How to avoid rerunning React useEffects

Jun 12, 2020

One of the gotchas with the React useEffect hook and other hooks that take a list of dependencies is that changes to these dependencies are tested for using a standard Javascript equality check. This is fine for primitive types such as numbers and strings but can be an issue with arrays and other object types.

For example, consider the following React component which takes an array of services and makes the named Firebase services available to child components:

export function Firebase({services, children}) {

  useEffect(
    () => {
      setUpFirebase(services)
      return () => {
        teardownFirebase()
      }
    },
    [services]
  )

  return children
}

Here, setupFirebase and teardownFirebase could be expensive operations that we only want to call when necessary. In this case, that would just be when the list of services changes.

We could call the component with:

<Firebase services={["auth", "database"]}>

However this would cause setupFirebase and teardownFirebase to be called every time the Firebase component is re-rendered. This is because a new ["auth", "database"] array is created each time this line is invoked.

We have specified that useEffect should only run if services changes. To determine this it will check the old value of services against the new value. So, in this case, unless ["auth", "database"] === ["auth", "database"] it will run the effect. But this will always return false and run the effect because these are different arrays (even though their contents is the same).

In order to retain the ability to dynamically specify different services and not change how we invoke the component, we need to derive something from the services array that will change only when the list of required services changes. We can then pass this derived value as a dependency to useEffect instead.

We know the service names will not contain commas, so a simple approach in this case is to use a comma-separated string of the sorted service names:

export function Firebase({services, children}) {

  const normalisedServices = [...services].sort().join()

  useEffect(
    () => {
      setUpFirebase(services)
      return () => {
        teardownFirebase()
      }
    },
    [normalisedServices]
  )

  return children
}

Javascript strings do compare equal if their contents are the same, so the new test ("auth,database" === "auth,database") will return true and the effect will not be run.

We are sorting the array so that the order the services are specified in is ignored (i.e. ["auth", "database"] and ["database", "auth"] will also compare equal). If you want to be really thorough and avoid re-running the effect if duplicate service names are specied then you could use a Set to dedupe them first:

const normalisedServices =
  [...new Set(services)].sort().join()

Then ["auth", "database", "auth"] and ["database", "auth"] would also compare equal and not cause the effects to be rerun.