Łukasz Makuch

Łukasz Makuch

React hooks... Oops! Part 2 - why does my effect run multiple times with the same dependencies?

Identical chairs

The Effect hook enables us to perform side effects, that is to run some imperative, effectful code. For example, we may want to fire a HTTP request after the component appears on the screen.

As we can see in the React documentation, the way we use the effect hook looks like this:

useEffect(fn, deps);

fn is the effectful function, and deps is an array of values it depends on. Every time the component renders, React checks if all the values in the deps array are still the same. If any of them has changed since the last render, fn is run again.

Even though it sounds pretty simple, there are some nuances to it that may lead to bugs or even crashing the browser (tab).

In this post from the React hooks... Oops! series, we'll focus on how the values in the deps array are compared. We'll also have a look at the way how values are compared in a totally different programming language.

Let's start with a simple example:

function App() {
  // Define a little state,
  // just a letter and a flag telling whether it's been updated.
  const [{ letter, updated }, setLetterObj] = useState({
    letter: "A",
    updated: false
  });

  // This function updates the letter.
  const setLetter = letter => setLetterObj({ letter, updated: true });

  // For the sake of clarity we declare
  // that the letter is what the effect depends on.
  const effectDependency = letter;

  // Run a dummy effect that simply logs the effectDependency.
  useEffect(() => console.log("effect of", effectDependency), [
    effectDependency
  ]);

  return (
    <>
      {updated && "New"} {letter}
      {/* Buttons that call setLetter every time they are clicked. */}
      <button onClick={() => setLetter("A")}>A</button>
      <button onClick={() => setLetter("B")}>B</button>
    </>
  );
}

This is how it works:

A basic example

Please note that even though we click each button, and consequently alter the letter value, multiple times, the effect is re-applied only if the value actually differs from the previous one.

To make it even more clear, let's add a simple counter to our example. The rest of the code remains intact:

function App() {
  // Nothing fancy here, just a number.
  const [counter, setCounter] = useState(0);

  const [{ letter, updated }, setLetterObj] = useState({
    letter: "A",
    updated: false
  });
  const setLetter = letter => setLetterObj({ letter, updated: true });
  const effectDependency = letter;
  useEffect(() => console.log("effect of", effectDependency), [
    effectDependency
  ]);

  return (
    <>
      {updated && "New"} {letter}
      <button onClick={() => setLetter("A")}>A</button>
      <button onClick={() => setLetter("B")}>B</button>
      {counter}
      {/* Clicking this button will increment the counter by 1. */}
      <button onClick={() => setCounter(counter + 1)}>+ 1</button>
    </>
  );
}

Now we can clearly see, that even thought the component is being re-rendered, the effect doesn't run unless the value it depends on changes:

With the counter

You might have been wondering what's the point of explicitly declaring that letter is the effectDependency. The reason behind it is that now we're going to derive some other value from letter, make it our new effectDependency, and see what happens.

Here we turned a boring dependency like "A" into something much more fun, like "The letter A":

function App() {
  const [counter, setCounter] = useState(0);

  const [{ letter, updated }, setLetterObj] = useState({
    letter: "A",
    updated: false
  });
  const setLetter = letter => setLetterObj({ letter, updated: true });
  // This is the only line that has changed.
  // We build a new string based on the letter string
  // and it's that new string 
  // that becomes the effect's dependency.
  const effectDependency = `The letter ${letter}`;
  useEffect(() => console.log("effect of", effectDependency), [
    effectDependency
  ]);

  return (
    <>
      {updated && "New"} {letter}
      <button onClick={() => setLetter("A")}>A</button>
      <button onClick={() => setLetter("B")}>B</button>
      {counter}
      <button onClick={() => setCounter(counter + 1)}>+ 1</button>
    </>
  );
}

The overall behavior hasn't changed. The effect is re-run only when the value of the dependency changes:

With a derived string dependency

All right, so far all the examples exhibit the same behavior. The effect simply doesn't run again if the dependency value doesn't change.

Let's take a look at one more example of a derived effect dependency and see if it follows the same rule:

function App() {
  const [counter, setCounter] = useState(0);

  const [{ letter, updated }, setLetterObj] = useState({
    letter: "A",
    updated: false
  });
  const setLetter = letter => setLetterObj({ letter, updated: true });
  // This time instead of deriving a string, we derive an object.
  const effectDependency = { payload: letter };
  useEffect(() => console.log("effect of", effectDependency), [
    effectDependency
  ]);

  return (
    <>
      {updated && "New"} {letter}
      <button onClick={() => setLetter("A")}>A</button>
      <button onClick={() => setLetter("B")}>B</button>
      {counter}
      <button onClick={() => setCounter(counter + 1)}>+ 1</button>
    </>
  );
}

Here's how it behaves in the browser:

An object as the payload

That's something new! Now the effect is re-applied on every render, even when the "value" of the payload stays the same.

Why?

The answer lies in the way how equality is understood. Depending on the data type, it's either value equality or reference equality.

Primitive data types, like strings, are always compared in terms of the value their represent. They may be two different strings, living in two separate parts of the computer memory, yet, if their values are equal, they are considered equal. Here's an example:

> let left = "A"
undefined
> let right = "B"
undefined
> left === right
false
> right = "A"
'A'
> left === right
true

If these were not variables but sticky notes, they would be considered equal if they had the same text written on them.

However, the same rule doesn't apply to objects. Comparing objects is all about the place in the computer memory where they are stored, not about the value they represent. Let's have a look:

> let left = { payload: "A" }
undefined
> let right = { payload: "B" }
undefined
> left === right
false
> right.payload = "A"
'A'
> left === right
false
> left = right
{ payload: 'A' }
> left === right
true

Only after making both references - left and right - point at the very same object, they were considered equal. Imagine left and right are your hands. If you used both of your hands to point at sticky notes, the question would be "Are both of your hands pointing at the very same note?" and not "Do the notes you're pointing at have the same text written on them?".

So how can we trigger an effect that needs an object and not a primitive value?

In this case it's quite easy to build the object inside the effect function. Instead of deriving an object from the primitive letter value outside of the effect function, we simply do it inside it. That way the dependency stays a primitive value and it's compared, well, based on it's value.

function App() {
  const [counter, setCounter] = useState(0);

  const [{ letter, updated }, setLetterObj] = useState({
    letter: "A",
    updated: false
  });
  const setLetter = letter => setLetterObj({ letter, updated: true });
  // It's not effectDependency = { payload: letter } anymore.
  // Instead, the dependency is a primitive value.
  const effectDependency = letter;
  useEffect(
    // Please note how we build { payload: effectDependency }
    // inside the effect function.
    () => console.log("effect of", { payload: effectDependency }),
    [effectDependency]
  );

  return (
    <>
      {updated && "New"} {letter}
      <button onClick={() => setLetter("A")}>A</button>
      <button onClick={() => setLetter("B")}>B</button>
      {counter}
      <button onClick={() => setCounter(counter + 1)}>+ 1</button>
    </>
  );
}

This time the effect 😉 is what we expected: A working object payload

Alternatively, when deriving the object from a couple of primitive values is not trivial, you may want to consider using Kent C. Dodds's useDeepCompareEffect hook.

It's similar to useEffect, but compares objects in terms of values, not references. That way effectDependency may be a new object with every render and the effect won't be unnecessarily re-applied. Have a look:

function App() {
  const [counter, setCounter] = useState(0);

  const [{ letter, updated }, setLetterObj] = useState({
    letter: "A",
    updated: false
  });
  const setLetter = letter => setLetterObj({ letter, updated: true });
  // We have an object as a dependency again.
  const effectDependency = { payload: letter };
  // Like useEffect, but compares values, not references.
  useDeepCompareEffect(
    () => console.log("effect of", effectDependency),
    [effectDependency]
  );

  return (
    <>
      {updated && "New"} {letter}
      <button onClick={() => setLetter("A")}>A</button>
      <button onClick={() => setLetter("B")}>B</button>
      {counter}
      <button onClick={() => setCounter(counter + 1)}>+ 1</button>
    </>
  );
}

With useDeepCompareEffect, the dependency may be an object and still be compared in terms of its value: A working object payload

To sum it up, when declaring effect dependencies, you should remember that only primitive data types are compared in terms of values and that's why it's safer to stick to them. However, if you really need an object (or an array, which is actually an object as well), then you may consider one of these 3 solutions:

  1. deriving it from some primitive values inside the effect function
  2. swapping useEffect for the useDeepCompareEffect hook
  3. ensuring that the reference to the dependency object changes only when its value changes

I don't know how about you, but I feel like the meaning of equality is pretty tangled in JavaScript. Interestingly, there are some programming languages that make it a whole lot simpler. Let's try to define and compare two strings in ClojureScript:

;; like let left = "A" in JS
(def left "A") 
;; like let right = "A" in JS
(def right "A") 

;; like left === right in JS
(= left right) ;; true

left and right have been compared in terms of their values. Now, let's try comparing a key-value map:

;; like let left = { payload: "A" } in JS
(def left {:payload "A"})
;; like let right = { payload: "A" } in JS
(def right {:payload "A"})

;; like left === right in JS
(= left right) ;; true

Yay! That's like comparing objects by value, and built into the language! Let's try something even more crazy, that is comparing sets (which are unordered collections of unique elements):

;; like let left = new Set(["A", "B", "C"]) in JS
(def left #{"A" "B" "C"}) 
;; like let right = new Set(["C", "B", "A"]) in JS
(def right #{"C" "B" "A"})

;; like left === right in JS
(= left right) ;; true

Wow! 😲 It supports comparing sets by value without the need of using any extra libraries!

You may find the snippets used in the writing of this post here:

Stay tuned for the next post!