React Dependency Arrays and Derived State

Dependency arrays are a very important part of React Hooks. They allow you to indicate what your effects, callbacks, and memos depend on so they can be recalculated only when necessary. For effects, this means your effects will be re-run every time one of your dependencies changes. The way that React determines if a dependency changes might not be the way you think, so you must be careful!

Dependency equality is checked with Object.is

React uses Object.is to determine if a dependency has changed since the last render. For primitives, like string, number, and boolean, this works exactly as you'd expect. The old value is compared with the new value. If they're different, then your hook will be run again.

However, for objects, referential equality is used:

Object.is({a: 1}, {a: 1}) === false

Even though those two objects have exactly the same keys and values, they do not have the same identity. Every time a new object is created in JavaScript, it is given its own identity. The only way for an object to be referentially equal to another object is if the two objects are the same object. We can pass two different references of the same object to Object.is, and the result would be true.

const obj1 = {a: 1};
const obj2 = obj1;
Object.is(obj1, obj2) === true

The Object.is documentation is very helpful.

Derived state and dependencies

It's often useful to derive some of a component's state from other state values or props. This reduces the amount of data that needs to be kept synchronized:

const Greeting = ({userName}) => {
  const greetingMessage = `Hello, ${userName}`;
  return <p>{greetingMessage}</p>;
}

If this derived state is used in an effect, then the effect will be re-run whenever the state changes. Usually this is desired:

const Greeting = ({userName}) => {
  const greetingMessage = `Hello, ${userName}`;
  useEffect(() => {
    console.log(`Displayed new greeting: "${greetingMessage}"`);
  }, [greetingMessage])
  return <p>{greetingMessage}</p>;
}

However, if the derived state we're using as a dependency is not referentially equal with the previous state, then the effect will be rerun on every single render:

const Greeting = ({userName}) => {
  const greeting = { message: `Hello, ${userName}` };
  useEffect(() => {
    console.log(`Greeting used for this render: "${greeting}"`);
  }, [greeting])
  return <p>{greeting.message}</p>;
}

Since the greeting object is recreated on every render in the code above, the effect is run on every render. This is the same result if we completely omitted the dependency array.

By adding new objects to our dependency array, we completely negate the benefits that come with using a dependency array. This results in greatly decreased efficiency for our component.

Which values are safe to use as dependencies?

I was recently working on a new component that made use of derived state that was returned by pre-existing functions from other parts of the code base. I didn't know exactly how those functions were creating their return values. They were returning objects, but I didn't know if they were being built from scratch

function greetingFromUser(user) {
  return { message: user.preferredGreeting.message };
}

or being plucked from an existing object that I was passing in:

function greetingFromUser(user) {
  return user.preferredGreeting;
}

Of course, I could read the source for those functions and come to a conclusion one way or the other, but that opens me up to human error. If I read the code incorrectly, I could end up with a new object in my dependency list of every render.

So I decided to do what any sane person would do: I'd use hooks to solve my problems with hooks.

I wrote a new hook useDependencySafetyCheck that would let me quickly and easily check if a variable was safe to use as a dependency:

const useDependencySafetyCheck = (dependencyObject) => {
  const label = Object.keys(dependencyObject)[0];
  const dependency = dependencyObject[label];
  const [count, setCount] = React.useState(1);
  React.useEffect(() => {
    console.log(`${label} changed ${count} times`, dependency);
    setTimeout(() => setCount(count => count + 1), 300);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [dependency]);;
};

This hook will print out the dependency's name and the number of times it has changed. For variables that are safe to use in a dependency array, this will be a finite number of times: typically once but possibly more. For variables that are not safe to use in a dependency array, this hook will print to the console every 300 ms. This should make it pretty obvious which variables are safe to use as dependencies and which are not.

How does useDependencySafetyCheck work?

First, let us look at how to use this hook. We simply pass the hook an object that contains the variable we want to check for dependency safety. If we use shorthand notation, then the object we pass in has one property that is the name of the variable, and the value of the property is the dependency we want to check:

const greeting = "Hello";
useDependencySafetyCheck({ greeting });

The hook creates an effect that depends on the dependency that was passed in. Every time the dependency changes, the hook's effect runs. When the hook's effect run, it prints out a line of text that shows how man times the effect has run, and it sets a timeout that increments the count of the number of times the effect has run.

For dependency array-safe variables, like strings, numbers, booleans, and stable objects, we'd expect to see only a limited number of lines printed by this hook.

Things get more interesting if we pass in a dependency that changes on every render:

const greeting = { message: "Hello" };
useDependencySafetyCheck({ greeting });

The useDependencySafetyCheck hook updates state every time its effect runs. React re-renders any component whose state changes. When our effect depends on a new object every render, it ends up running on every render. Every time the effect runs, it updates state. This results in an infinite render loop where a new line is printed to the console on every render.

I actually crashed Chrome when I originally wrote this hook because I did not include the timeout before updating the counter state. The hook ate up all of the processing time and made it impossible for me to close Chrome. This issue explains why there is a 300 ms delay added in before the hook updates its counter.

An example for useDependencySafetyCheck

I've produced a useDependencySafetyCheck Code Pen for this hook where you can experiment with passing different variables to it.

If you'd like to learn more about React, I recommend React js: The Complete Beginner's Guide to React - 2nd Edition (2020); it has been a great reference for me.

Photo by Eric Karim Cornelis


Newer →
No newer posts