This site runs best with JavaScript enabled.

Referential Equality in React


How referential equality can bite you when using react and how to avoid it.

The problem

Last week I was helping out a co-worker with a bug they were experiencing. While building a custom hook they were retrieving some data, manipulating that data and setting state. Even though their logs were showing the data was updated, the component wasn't rendering what was being logged. Their code looked something like this:

1const initialData = {
2 foo: {
3 list1: [],
4 list2: [],
5 },
6 bar: {
7 list1: [],
8 list2: [],
9 },
10};
11
12const useCustomData() {
13 const [data, setData] = React.useState(initialData);
14 React.useEffect(() => {
15 fetch('/path/to/api')
16 .then(res => res.json())
17 .then(data => data.reduce(transformFn, initialData))
18 .then(setData);
19 }, [])
20 return data;
21}

Did you spot it? If not thats ok. This particular bug is subtle and easily missed.

How react determines when it should re-render

In the React docs we read the following:

The setState function is used to update the state. It accepts a new state value and enqueues a re-render of the component.

What this is saying is, that anytime we call the state updater function (setData) returned from useState react will ingest that and trigger a re-render of our component. But this wasn't happening for us. Why not?

Further down in reacts docs on useState there is this section about bailing out of state updates.

If you update a State Hook to the same value as the current state, React will bail out without rendering the children or firing effects. (React uses the Object.is comparison algorithm.)

So when our updater function gets called, react will check the value we pass to it for equality against what it is currently holding in state and if they're the same it will bail out of re-rendering our component.

The Object.is Algorithm

If we look at the docs for Object.is on mdn we will find the description of the algorithm that is used for state update comparisons.

Object.is() determines whether two values are the same value. Two values are the same if one of the following holds:

  • both undefined
  • both null
  • both true or both false
  • both strings of the same length with the same characters in the same order
  • both the same object (means both objects have same reference)
  • both numbers and
    • both +0
    • both -0
    • both NaN or
    • both non-zero and both not NaN and both have the same value

The interesting part of this algorithm is how it deals with detrmining if two objects are equal. This is done by the objects reference stored in memory. To fully explain this we have to learn about what happens to an objects reference when we update one.

Object reference

When a new object is created and bound to a variable what is bound is not the object itself but a reference to the location of that object in memory. For example:

1const obj = {}

obj would store a memory location instead of the object itself. The result of this is that when we reference the bound variable we are no longer referencing the value of the object but instead we reference whatever is stored at that location in memory. This is done for performance optimization reasons that is outside the scope of this article.

Solving our problem

Lets unwind what we have learned. Assigning objects to variables gives us a memory location instead of the value of the object. React then uses the reference to that memory location to determine if two objects are different and only re-renders when the two objects are stored in different places in memory. So if we take another look at our code through the lense of what is bound to our variables. Our bug begins to make more sense. For simplicity we will represent objects memory location with strings.

1const initialData = 'memoryLocation1';
2
3const useCustomData() {
4 const [data, setData] = React.useState('memoryLocation1');
5 React.useEffect(() => {
6 fetch('/path/to/api')
7 .then(res => res.json())
8 .then(data => data.reduce(transformFn, 'memoryLocation1'))
9 .then(setData);
10 }, [])
11 return data;
12}

with this psuedocode we can see that what we are initializing both useState and our reduce fn accumulator to the object stored at memoryLocation1. Meaning that when we call setData we are setting it with the same object reference. Which kicks off the following conversation:

Us: "Hey React can you update our state?"

React: "Sure. What do you want me to update it with?"

Us: "Please update it with the object stored at memoryLocation1"

React: "No problem! Looks like I've already got that set in state nothing to do here!"

Us: "No wait! React! There is definitely stuff to do because we updated the properties of the object! 😡"

So how do we solve this problem? Luckily the solution is fairly simple. We just have to initialize our reducer function with a totally new object so that the memory location doesn't match what is already stored in state. One way we could do this would look like this:

1function createInitialObject() {
2 return {
3 foo: {
4 list1: [],
5 list2: [],
6 },
7 bar: {
8 list1: [],
9 list2: [],
10 },
11 };
12}
13
14const useCustomData() {
15 const [data, setData] = React.useState(createInitialObject());
16 React.useEffect(() => {
17 fetch('/path/to/api')
18 .then(res => res.json())
19 .then(data => data.reduce(transformFn, createInitialObject()))
20 .then(setData);
21 }, [])
22 return data;
23}

This will ensure that we are creating a totally new object each time we invoke our createInitialObject function.

Conclusion

When working with state in react be mindful of how data is stored in memory and how react determines that something has changed. In most cases objects are the primary sticking point. So if you want re-renders to be triggered make sure you are setting state with entirely new objects!

Discuss on TwitterEdit post on GitHub

Share article
Tyler Haas

Tyler Haas is a full stack software engineer and primarily focusing on the front end. He has worked with companies of all sizes to help them deliver robust applications. He lives with his wife and three kids in Utah.