Demystifying Object.is and prevState in React useState

What Object.is() has to do with React useState? Let's find out.

Demystifying Object.is and prevState in React useState

React is a fantastic library, but sometimes it also forces developers to deal with somewhat leaky implementation details.

One of these details is the algorithm it uses behind the scenes to check if a given state object is actually changed or not. There is nothing really magic behind this mechanic, but the behaviour of useState() trips up most newcomers (and I can't blame them). Let's see why.

Demystifying Object.is

Object.is() in JavaScript is a value comparison operator. Despite being attached to Object.prototype, it can be used to check any JavaScript value, be them primitives or objects. For the scope of this post we gloss over the nitty-gritty to focus only on plain JavaScript objects and arrays.

Consider the following object:

const person = { name: "Jules" };

With a quick check we can compare this object to itself with Object.is(), which will indeed return true:

Object.is(person, person);
// Output: true

Now, we know that objects in JavaScript are mutable, and the fact that here we assigned it to a const does not make any difference whatsoever. In fact, we can change any property in the object, add more properties, or delete existing ones:

person.age = 33;
person.name = "Ju";
person.city = "Rome";
delete person.name;

Our person object now is not the same person anymore for us. It has an age, a city, and the name is not there anymore. How about for Object.is()? Let's see:

Object.is(person, person);
// Output: true

It doesn't matter whether we manipulate the object properties or not, for the Object.is() algorithm, person is still the same object.

How does this have something to do with React? Keep reading!

Understanding Object.is in React

Consider the following component:

import { useState } from "react";

export default function MyComponent() {
    const [person, setPerson] = useState({
        name: "Jules"
    });

    function changeName() {
        person.name = "Chris";
        setPerson(person);
    }

    return (
        <div>
            <h1>Hello {person.name}</h1>
            <button onClick={changeName}>CHANGE NAME</button>
        </div>
    );
}

Here we use the useState() hook to hold an object in our state. In turn, useState() gives back the initial state, and a state updater function named setPerson().

In the component, we have also a button to trigger a name change through the changeName() function, which does something apparently harmless:

function changeName() {
    person.name = "Chris";
    setPerson(person);
}

It takes the initial state, changes the name property of the person, and sends back the object to update the state through setPerson().

The result from this UI is that despite furiously clicking the button, the name won't change. Why?

As we saw previously, changing an object property does not change its address in memory. Under the hood, React uses Object.is() to determine if the new state provided in the state updater function is different from the previous state.

In our case, the person object in the state remains the same object even after messing up with its properties.

This is a common source of confusion for newcomers to React, which I've sometimes seen spend half an hour to understand why the UI isn't updating.

Is there anything can we do to make friends with Object.is()? Let's see.

Making friends with Object.is, and understanding prevState in useState

When the state of our component is a simple object like the one we saw before, we can avoid being bite by Object.is() by passing to the state updater function a new object with the new state. The following example shows the changeName() function aptly fixed:

  function changeName() {
    setPerson({
        name: "Chris"
    });
}

When React compares the previous state object with the new state, it will find out that this new object is not the same object as before. Thus, it will trigger a re-render.

It's ok in our case to write the new state by hand, but for larger state objects this is impractical.

Instead, we can pass a callback function to the state updater function. This callback takes the previous state as a parameter, which can now be used to merge our updates with the previous state. The following example shows a slightly "larger" state, and a state updater function which makes use of prevState:

import { useState } from "react";

export default function MyComponent() {
    const [person, setPerson] = useState({
        name: "Jules",
        age: 33,
        city: "Malaga"
    });

    function changeName() {
        setPerson((prevState) => {
            return { ...prevState, name: "Chris" };
        });
    }

    return (
        <div>
            <h1>Hello {person.name}</h1>
            <h2>You are from {person.city}</h2>
            <button onClick={changeName}>CHANGE NAME</button>
        </div>
    );
}

Let's see the relevant bit in detail:

setPerson((prevState) => {
    return { ...prevState, name: "Chris" };
});

Here we create a new object by spreading the previous state into the new container. We also overwrite name which wins over the initial property.

Note: prevState is just a convention to denote "the previous state". You can name this parameter as you wish.

In most cases, using this pattern is also important for another reason: the state updater function created by useState() does not merge automatically the previous state with the new state. This means that if we forgot to include any property from the previous state, React will replace the original object with our partial changes.

Does the problem exists only for objects?

Since Array in JavaScript is a subtype of Object, Object.is() behaves similarly when used against an array. For example:

const arr = ["a", "b", "c", "d"];
Object.is(arr, arr)
// Output: true

arr[1] = "x";
Object.is(arr, arr)
// Output: true

Despite changing an element of the array, the array is still equal to itself. This means that in a React component, the following example won't trigger a re-render:

import { useState } from "react";

export default function MyComponent() {
    const [arr, setArr] = useState([1, 2, 3]);

    function shift() {
        arr.shift();
        setArr(arr);
    }

    return (
        <div>
            {arr.map((el) => {
                return <span>{el}</span>;
            })}
            <button onClick={shift}>SHIFT</button>
        </div>
    );
}

To fix the logic, we must pass a new array to the state updater function. For example, we can spread the modified array into a new one:

  function shift() {
    arr.shift();
    setArr([...arr]);
}

Takeaways

  • Always pass a new object to the state updater function from useState(), don't simply change one of its properties
  • When necessary use the prevState argument to merge the previous state into the new one
Valentino Gagliardi

Hi! I'm Valentino! I'm a freelance consultant with a wealth of experience in the IT industry. I spent the last years as a frontend consultant, providing advice and help, coaching and training on JavaScript, testing, and software development. Let's get in touch!