0

I have the following code :-

const [dice, setDice] = React.useState(allNewDice()) // Array of objects with the following properties: `value` (random digit between 1 and 6), `id` (unique id), `isHeld` (boolean)

function holdDice(id) { 
    const heldDie = dice.findIndex(die => die.id === id)
    
    setDice(prevDice => {
        prevDice[heldDie].isHeld = !prevDice[heldDie].isHeld
        
        return prevDice
    })
}

The function holdDice is working as expected. It updates prevDice's first element's isHeld property to the opposite of what was previously, as evident from logging it to the console. But the only problem is, the state is not getting updated.

So I tried making a copy of prevDice and then returning the copy (using the spread (...) operator) :-

const [dice, setDice] = React.useState(allNewDice()) // Array of objects with the following properties: `value` (random digit between 1 and 6), `id` (unique id), `isHeld` (boolean)

function holdDice(id) { 
    const heldDie = dice.findIndex(die => die.id === id)
    
    setDice(prevDice => {
        prevDice[heldDie].isHeld = !prevDice[heldDie].isHeld
        
        return [...prevDice]
    })
}

And this works perfectly.

Can anyone please explain why this behaviour? Why can't I return the previous state passed in to be set as the new state, even when I am not returning the previous state, exactly as it was?

I understand that it's expensive to re-render the component and that maybe React does not update the state when the previous and the new states are equal. But here, the previous and the new states are not equal.

My first guess was that it might have been related to React not deep checking whether the objets of the array prevDice have been changed or not, or something like that. So I created an array of Numbers and tried changing it's first element to some other number, but unless i returned a copy of the array, the state still did not change.

Any help would be greatly appreciated. Thank you!

HerrAlvé
  • 587
  • 3
  • 17
  • 1
    In your case you are modifying `prevDice` directly, which is the same as modifying your state object directly. The `prevDice` variable is a reference to your state, so updating it updates your current state value. As a reseult, when you call `setDice()` with the same object reference, no state updates occur (because react will just compare the two object references to see if they are equal, `currState === newState`) – Nick Parsons May 23 '22 at 12:54
  • 1
    It's for the same reason why something like `const x = [0]; const y = x; y[0] = 1; console.log(x === y);` gives `true`, because we've only created one array (`[0]`) which is stored in memory, and both `x` and `y` point to that same array in memry. Since they're refering to the same thing, `x === y` is true. In your example, `x` is like the current state and `y` is like `prevState`. – Nick Parsons May 23 '22 at 12:58
  • @NickParsons Thank you so much! I now know exactly what was the problem was. Please post this as an answer. I will immediately mark it as the accepted one :) – HerrAlvé May 23 '22 at 15:44
  • Also, could you please explain why React passes in the previous state's reference instead of passing in a copy? And why is it good practice to access the previous state, instead of the state directly? Thank you :) – HerrAlvé May 24 '22 at 03:16
  • That's the nature of JS, when you pass an object/array to a function, the parameter will refer to the same object reference that was passed to the function when it was called. Using a callback function in your state setter is good practice due to `setDice()` being asynchronous and how it only updates the `dice` contents when the next rerender occurs (see [here](https://stackoverflow.com/a/48209870/5648954) for more details) – Nick Parsons May 24 '22 at 13:20

2 Answers2

1

The first time you call setDice() after the initial mount of your component or rerender, prevDice refers to the same array in memory that your dice state refers to (so prevDice === dice). It is not a unqiue copy of the array that you're able to modify. You can't/shouldn't modify it because the object that prevDice refers to is the same object that dice refers to, so you're actually modifying the dice state directly when you change prevState. Because of this, when the modified prevDice is used as the value for setDice(), React checks to see if the new state (which is prevState) is different from the current state (dice) to see if it needs to rerender. React does this equality check by using === (or more specifically Object.is()) to check if the two arrays/objects are the same, and if they are, it doesn't rerender. When React uses === between prevState and dice, JS checks to see if both variables are referring to the same arrays/objects. In our case they are, so React doesn't rerender. That's why it's very important to treat your state as immutable, which you can think of as meaning that you should treat your state as readonly, and instead, if you want to change it, you can make a "copy" of it and change the copy. Doing so will correctly tell React to use that new modified copy as the new state when passed into setDice().

Do note that while your second example does work, you are still modifying your state directly, and this can still cause issues. Your second example works because the array you are returning is a new array [], but the contents of that array (ie: the object references) still refers to the same objects in memory that your original dice state array referred to. To correctly perform your update in an immutable way, you can use something like .map(), which by nature returns a new array (so we'll rerender). When you map, you can return a new inner object when you find the one you want to update (rather than updating it directly). This way, your array is a new array, and any updated objects are also new:

function holdDice(id) {  
    setDice(prevDice => prevDice.map( // `.map()` returns a new array, so different to the `dice`/`prevDice` state
      die => die.id === id 
        ? {...die, isHeld: !die.isHeld} // new object, with overwritten property `isHeld`
        : die
    ));
}
Nick Parsons
  • 45,728
  • 6
  • 46
  • 64
  • Thank you so much for all your time and effort!! This is a really comprehensive answer. But all I've got left is just one doubt regarding the usage of `Object.is`:- Why does react check if the previous state and the new state are referring to the same array? What's the reason behind it? Why doesn't it check whether the values of both the arrays are the same or not? Basically, why is it comparing the previous and new state's references in memory instead of comparing each of their values? – HerrAlvé May 26 '22 at 04:10
  • 1
    @AlveMonke I can only guess as to why, performing a comparison with `Object.is()` is much more efficient. Natively, JavaScript doesn't have a way to compare objects like arrays by comparing the values it holds (ie: there is no native way to "deeply" compare objects). This would mean that the React team would need to write logic to traverse through your array and compare each value within your new state array to the old state. This isn't difficult if we're just working with arrays and primitives, but it becomes more complex when we're dealing with arrays of objects, or deeply nested structures. – Nick Parsons May 26 '22 at 04:38
  • 1
    Using `Object.is()` or something like `===` is much more efficient. Comparing references like this is native JS functionality. As React doesn't need to compare your entire state object's values each time you set your state to decide if it needs to rerender. It knows right-away if the state you're passing is different by checking if the object references are different, and if they are, it knows to rerender, and if they are the same, it doesn't rerender. So it saves a bunch of time by avoiding needing to traverse through your new state and comparing each value to the old state. – Nick Parsons May 26 '22 at 04:43
1

State doesn't update because you update it yourself in the setDice function. dice and prevDice refer to the same object so it won't trigger a re-render.

const [dice, setDice] = React.useState(allNewDice()) 

function holdDice(id) { 
    const heldDie = dice.findIndex(die => die.id === id)
    
    setDice(prevDice => {
        // Create a copy of the dice
        // Use whatever method you wish.Spread. JSON parse and stringify.
        const currentDice = JSON.parse(JSON.stringify(prevDice))
        currentDice[heldDie].isHeld = !prevDice[heldDie].isHeld
        
        return currentDice
    })
}