Playing around application states in ReactJS or VueJS is quite fun. But it’s not fun anymore if we (accidentally) mutate the state while doing a search from the state.

It happens to me when I’m trying to find a sidebar ID from the store of the sidebar tree. Yes, a tree. Imagine the store if we have a sidebar tree like this

Sidebar tree example Sidebar tree example

and we need to find out the ID of file2.xml. Our best approach here is to apply a DFS (Depth-First Search) without re-render the sidebar. And here’s my first approach to DFS

dfs.js
  • js
1
2
3
4
5
6
7
8
9
10
11
12
13
function dfs(id) {
  const tree = [state.list]

  while (tree.length) {
    const node = tree.shift()

    if (node.id === id) {
      return node
    } else if (node.children.length) {
      tree.unshift(...node.children)
    }
  }
}

if you notice it, we’re using .shift() and .unshift() here. Unfortunately, the sidebar does re-render and it’s quite slow. It’s because the .shift() time complexity is at O(n) at worst!

Using Array.pop()

After some research, it seems that we can change from .shift() to .pop(). Since the .pop behavior is different, we need to change the code accordingly

dfs.js
  • js
1
2
3
4
5
6
7
8
9
10
11
12
13
function dfs(id) {
  const tree = [state.list]

  while (tree.length) {
    const node = tree.pop() // change to `.pop`

    if (node.id === id) {
      return node
    } else if (node.children.length) {
      tree.push(...node.children) // push the children to the array so we can iterate it again
    }
  }
}

this change is quite performant compared to use .shift() before. But unfortunately, it still does some re-render! The sidebar items somehow got removed and added back themselves. We didn’t do a mutation to store, though. What happens?

The Culprit

Debugging for hours and finally, I found the culprit! We can see that we’re doing some assignments to a new variable called tree here.


function dfs(id) {
  const tree = [state.list] // <-- this assignment still have value reference to the original
...

In the JS world, doing assignments from an array won’t cut the value reference to the original array. In this particular case, it’s the state.list. Here’s an example for value reference of an array

array.js
  • js
1
2
3
4
5
6
7
8
9
10
const array = [1, 2, 3, 4];
const newArray = array;

console.log(newArray); // output: [1, 2, 3, 4]

newArray.push(5);
console.log(array); // output: [1, 2, 3, 4, 5]

// 😱 newArray value got altered too
console.log(newArray); // output: [1, 2, 3, 4, 5]

the solution is to deep copy the array. So the nested structure will be copied without any reference to the original. To do the deep copy, we have several methods.

JSON.stringify + JSON.parse duet

Here is an example of deep copy using these duet

deep-copy.js
  • js
1
const tree = JSON.parse(JSON.stringify(state.list))

By far, this is the simplest, yet native approach without using any library. But read these gotchas

“If you do not use Dates, functions, undefined, Infinity, [NaN], RegExps, Maps, Sets, Blobs, FileLists, ImageDatas, sparse Arrays, Typed Arrays or other complex types within your object, a very simple one liner to deep clone an object is: JSON.parse(JSON.stringify(object))” — StackOverflow answer from Dan Dascalescu

here is a demonstration of it (copied from this gist)

deep-copy.js
  • js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Only some of these will work with JSON.parse() followed by JSON.stringify()
const sampleObject = {
  string: 'string',
  number: 123,
  boolean: false,
  null: null,
  notANumber: NaN, // NaN values will be lost (the value will be forced to 'null')
  date: new Date('1999-12-31T23:59:59'),  // Date will get stringified
  undefined: undefined,  // Undefined values will be completely lost, including the key containing the undefined value
  infinity: Infinity,  // Infinity will be lost (the value will be forced to 'null')
  regExp: /.*/, // RegExp will be lost (the value will be forced to an empty object {})
}

console.log(sampleObject) // Object { string: "string", number: 123, boolean: false, null: null, notANumber: NaN, date: Date Fri Dec 31 1999 23:59:59 GMT-0500 (Eastern Standard Time), undefined: undefined, infinity: Infinity, regExp: /.*/ }
console.log(typeof sampleObject.date) // object

const faultyClone = JSON.parse(JSON.stringify(sampleObject))

console.log(faultyClone) // Object { string: "string", number: 123, boolean: false, null: null, notANumber: null, date: "2000-01-01T04:59:59.000Z", infinity: null, regExp: {} }

// The date object has been stringified, the result of .toISOString()
console.log(typeof faultyClone.date) // string

With these gotchas, we’ll try next method

Lodash _.cloneDeep

The lodash library is very popular among JS developers. Lodash has several utility functions that make coding in JS easier and cleaner. Using _.cloneDeep is quite easy too. Here’s an example

clone-deep.js
  • js
1
2
3
4
const objects = [{ 'a': 1 }, { 'b': 2 }];
 
const deep = _.cloneDeep(objects);
console.log(deep[0] === objects[0]); // output: false

Conclusion

Deep copy is the answer for this particular case. If we need to avoid mutation to the original object, we can use this method to avoid that. And here’s the final version using deep copy

deep-copy.js
  • js
1
2
3
4
5
6
7
8
9
10
11
12
13
function dfs(id) {
  const tree = _.cloneDeep(state.list) // only change this line

  while (tree.length) {
    const node = tree.pop()

    if (node.id === id) {
      return node
    } else if (node.children.length) {
      tree.push(...node.children)
    }
  }
}

References