DEV Community

Cover image for Understanding referential equality in React's useEffect
Victor Novais
Victor Novais

Posted on

Understanding referential equality in React's useEffect

Hello fellow readers!
In this post I am going to discuss how useEffect handles it's dependencies when there is an object in it.

Note: there will be assumptions that you know some key concepts about useEffect. So, if you don't really know the basics, I first recommend you to read the React docs on this subject.

Referential equality

When we talk about comparison in most of programming languages, we deal with two topics: comparison by reference and comparison by value.
In JavaScript world, this is also true. We can compare values using primitive types, like string or numbers, or compare references when dealing with objects.

Comparison by value

This is the most straightforward concept. If two values are equal, then a boolean comparison returns true. Note that this works for the most common primitive types of JavaScript (strings, numbers and booleans).

const a = 1;
const b = 1;
const c = 2;
console.log(a === b); // true
console.log(b === c); // false

const d = 'hello';
const e = 'hello';
const f = 'bye';
console.log(d === e); // true
console.log(e === f); // false
Enter fullscreen mode Exit fullscreen mode

Comparison by reference

This type of comparison takes in consideration where in the memory an object is located. If two objects point to the same location, they are equal, otherwise they are different. Check out the following schema:

Drawing showing two objects pointing to two different memory pieces

Even if two objects have the same properties with the same values, they will not be equal, unless they are located in the same memory position. You can run the following code in your browser's DevTools to prove this:

const obj1 = { animal: 'dog' };
const obj2 = { animal: 'dog' };
const obj3 = obj1

console.log(obj1 === obj1) // true
console.log(obj1 === obj2) // false
console.log(obj2 === obj3) // false
console.log(obj1 === obj3) // true
Enter fullscreen mode Exit fullscreen mode

Comparison in React's useEffect

With the previous introduction on types of comparison on mind, let's bring that concept into React's hook useEffect.
Accordingly to React's docs, we can define this hook as:

The Effect Hook lets you perform side effects in function components. Data fetching, setting up a subscription, and manually changing the DOM in React components are all examples of side effects. Whether or not you’re used to calling these operations “side effects” (or just “effects”), you’ve likely performed them in your components before.

If we need to run an effect after a specific change, we must use hook's second argument, which is an array of dependencies:

useEffect(() => {
  document.title = `You clicked ${count} times`;
}, [count]); // Only re-run the effect if count changes
Enter fullscreen mode Exit fullscreen mode

Every time any of the dependencies change, the callback inside useEffect is run, and in this process it is important to know how the comparison is made.

If there are only primitive values such as string or number, there will be a comparison by value, otherwise there will be a comparison by reference.

I've seen plenty of times errors regarding the functionality of useEffect when it comes to dependencies. You may trap yourself in an infinite loop or multiple calls to an API, which may incur in money loss if, for example, your back-end is hosted in a cloud service. To mitigate these problems, it is important to keep these dependencies as stable as possible.

So, let's see some examples.

  • useEffect + value comparison: this example show a simple count component that render in screen a new text every time the count state changes. As it is a number, React simply compares if the previous number and the new number are different, if this is true, then useEffect is called.
const ValueComparison = () => {
  const [count, setCount] = useState(0);

  useEffect(() => {
    document.body.append(`Whoa! My count is now: ${count}`);
    var br = document.createElement('br');
    document.body.appendChild(br);
  }, [count]);

  return <button onClick={() => setCount(count + 1)}>Click me to count</button>;
};
Enter fullscreen mode Exit fullscreen mode

Gif showing a button that when clicked increments a counter and renders in screen a text with the current count

  • useEffect + reference comparison (1): the following example shows a common problem. It shows a object state that is directly changed, but nothing is rendered. Check it out:
const ReferenceComparison1 = () => {
  const [animalObj, setAnimalObj] = useState({ animal: 'dog' });

  const handleChange = () => {
    animalObj.animal = animalObj.animal === 'cat' ? 'dog' : 'cat';
    setAnimalObj(animalObj);
  };

  useEffect(() => {
    document.body.append(`I am this animal: ${animalObj.animal}`);
    var br = document.createElement('br');
    document.body.appendChild(br);
  }, [animalObj]);

  return <button onClick={handleChange}>Click me to change the animal</button>;
};
Enter fullscreen mode Exit fullscreen mode

Gif showing a non working button that when clicked does nothing

You may be asking yourself, baffled: but the state did change! Now the animal should be a cat!
Well... not quite. We are changing an object property, not the object per se. See, remember that an object comparison is made by reference? So, the reference of the object in the memory stays the same even if some property changes, thereby useEffect dependency will not recognize any change.

To fix this, we simply need to pass a new object to setAnimalObj, meaning that this new object will point to a new memory location, so the dependency will change and useEffect will fire:

const ReferenceComparison1 = () => {
  const [animalObj, setAnimalObj] = useState({ animal: 'dog' });

  const handleChange = () => {
    setAnimalObj({
      ...animalObj,
      animal: animalObj.animal === 'cat' ? 'dog' : 'cat',
    });
  };

  useEffect(() => {
    document.body.append(`I am this animal: ${animalObj.animal}`);
    var br = document.createElement('br');
    document.body.appendChild(br);
  }, [animalObj]);

  return <button onClick={handleChange}>Click me to change the animal</button>;
};
Enter fullscreen mode Exit fullscreen mode

Gif showing a button that when clicked renders in screen wether the current animal is a dog or a cat

  • useEffect + reference comparison (2): now let's see an example with a parent-child component relationship:
// Here is the parent component that renders an animal list and a button that increments a counter
const ReferenceComparison2 = () => {
  const [count, setCount] = useState(0);
  const animalList = [
    { animal: 'dog' },
    { animal: 'cat' },
    { animal: 'turtle' },
  ];

  return (
    <React.Fragment>
      <ChildComponent data={animalList} />
      <span>Count: {count}</span>
      <button onClick={() => setCount(count + 1)}>Increment count</button>
    </React.Fragment>
  );
};

// Here is the child component, responsible for rendering the list used by parent component
const ChildComponent = ({ data }: ChildComponent1Props) => {
  useEffect(() => {
    document.body.append(`Child rendered! Data has changed!`);
    var br = document.createElement('br');
    document.body.appendChild(br);
  }, [data]);

  return (
    <ul>
      {data.map((item, index) => (
        <li key={index}>{item.animal}</li>
      ))}
    </ul>
  );
};
Enter fullscreen mode Exit fullscreen mode

If we run the above code, we can see that the child component is rerendered every time the button is clicked, although the counter and the list are independent (also animalList hasn't changed any property at all):

Gif showing an animal list with a button. Below these, there is a text showing how many times the child component was rendered

This happens because every time the counter is updated, the parent component is rerendered, therefore the function will be called again, generating a new reference for the object in animalList variable. Finally, the child component acknowledge this change and runs useEffect.

Drawing representing that the animalList variable points to different memory blocks on each render

It is possible to solve this in many ways, let's see two of them. The first solution below simply moves the array data outside the component function, therefore the object reference will never change:

const animalList = [{ animal: 'dog' }, { animal: 'cat' }, { animal: 'turtle' }];

const ReferenceComparison2 = () => {
  const [count, setCount] = useState(0);

  return (
    <React.Fragment>
      <ChildComponent data={animalList} />
      <span>Count: {count}</span>
      <button onClick={() => setCount(count + 1)}>Increment count</button>
    </React.Fragment>
  );
};
Enter fullscreen mode Exit fullscreen mode

The second possible solution is to use useMemo. This hook keeps the same reference of a value unless its dependencies change:

const ReferenceComparison2 = () => {
  const [count, setCount] = useState(0);
  const animalList = useMemo(
    () => [{ animal: 'dog' }, { animal: 'cat' }, { animal: 'turtle' }],
    []
  );

  return (
    <React.Fragment>
      <ChildComponent data={animalList} />
      <span>Count: {count}</span>
      <button onClick={() => setCount(count + 1)}>Increment count</button>
    </React.Fragment>
  );
};
Enter fullscreen mode Exit fullscreen mode

Now our child component will not run useEffect, because the data dependency has a stable reference:

Gif showing that now, when the increment counter button is clicked, the child component does not rerender

Wrapping up

We've seen how referential equality works when using useEffect. It is always important to keep an eye on the dependencies, specially if they rely on objects, arrays or functions.
You may find yourself sometimes in trouble when the same effect runs many times. If this happens, remember to checkout the dependencies and if they are stable.
Feel free to use the comments section to expose your opinion or ask me anything! Thanks!

Top comments (0)