Why shallow copying nested state objects in React can cause bugs?

·

4 min read

Do you spot a bug in the React snippet below?

Selecting an item in any one of the two lists causes the same item to get selected in the other list as well. samList and janeList are supposed to be isolated but their state seems to accidentally shared. See if you can find what part of the code causes this issue.

Couldn't find it. No problem, let's draw back a bit and find out why working with nested objects such as above can be tricky. (Mind you Arrays are objects, too)

Immutability in React

React requires the state to be immutable which means every time a state variable has to be updated, it can't be done directly. One must create a new variable and pass it to the state's setter function (setState).

React does so because it uses virtual DOM. Any state changes are reflected in this virtual DOM.

Before every mutation to the actual DOM, react compiler compares the virtual DOM to the version before state change through the process called reconciliation. Having the state - immutable makes this process faster as one can check for change in state using reference equality operator (===).

Read more about Immutability in React on here

In order to maintain immutability in state updates one must be extra careful when working with nested objects as all standard built-in object-copy operations in Javascript (...spread syntax, Array.prototype.concat(), Array.prototype.slice(), Array.from(), Object.assign(), and Object.create()) create shallow copies rather than deep copies.

You may wonder why shallow copying state can cause bugs. The answer lies in how objects in Javascript work.

Shallow vs Deep Copy

const objOne = { a: 1, b: 2, c: 3, d: 4 };

const objX = objOne;
const objY = { ...objOne };
console.log(objX === objOne); // true
console.log(objY === objOne); // false

objX and objOne hold the reference to same address in memory, hence reference equality of the two returns true. While objY is created by copying objOne and both point to different addresses in memory, hence their reference equality returns false.

Things get slightly precarious when using built-in object-copy operators to copy nested objects.

let objTwo = {a: 1, b: 2, c: 3, d: { x: 1, y: 2 }};

let objM = { ...objTwo };

console.log(objM === objTwo); // false
console.log(objM.d === objTwo.d); // true

// Before modification
console.log(objM.d, objTwo.d); // {x: 1, y: 2}, {x: 1, y: 2}

objTwo.d.x = 100;

// After modification
console.log(objM.d, objTwo.d); // {x: 100, y: 2}, {x: 100, y: 2}

objM = JSON.parse(JSON.stringify(objTwo)); // deep copy

console.log(objM.d === objTwo.d); // false

On copying objTwo into objM using spread operator, objM.d === objTwo.d returns true because spread operator performs a shallow copy which basically means certain properties or sub-properties of the newly created objected are still connected to original object's properties. For example, here objM.d and objTwo.d refer to same address in memory. This also means changing one will change another unless you do re-assignment.

According to MDN,

A shallow copy of an object is a copy whose properties share the same references (point to the same underlying values) as those of the source object from which the copy was made. As a result, when you change either the source or the copy, you may also cause the other object to change too — and so, you may end up unintentionally causing changes to the source or copy that you don't expect. That behavior contrasts with the behavior of a deep copy, in which the source and copy are completely independent.

Fixing the bug

Now coming back to the bug in our react snippet above. The code which causes it is on Line 22 and Line 33 (in both toggle handlers).

const handleSamListToggle = (e, id) => {
   const prev = samList;
   const newList = prev.map((item) => {
      if (item.id === id) {
        item.hasBought = e.target.checked;
      }
      return item; // Bug
    });
    setSamList(newList);
  };

Although prev array is a new array, the items in it point to the original groceries array which is used to initialize both the state lists (samList and janeList). These two lists have item objects nested in them and each corresponding item refers to the same memory address. And we saw earlier changing one will cause another to change too.

console.log(samList[0] === janeList[0]); // true
// Item in the lists refer to same value in memory.

If we spread the item object while mutating the state on toggle, this would copy the values in it and items in samList and janeList will no longer refer to same values. This would solve the bug.

const handleSamListToggle = (e, id) => {
   const prev = samList;
   const newList = prev.map((item) => {
      if (item.id === id) {
        item.hasBought = e.target.checked;
      }
      return {...item}; // Fix
    });
    setSamList(newList);
  };

console.log(samList[0] === janeList[0]); // false

To summarize, deep copy your state object whenever you want to change the value of its nested properties else it could lead to bugs which could be harder to track.

References