Have you ever had one of those times when you think you’re doing everything right, yet still you get an unexpected bug in your application? Particularly when it is state-related and you thought you did everything you could to isolate the state by making copies instead of mutating it in place.
Especially when you are, say, building a game that copies a blank initial state when you create a new room and, no matter what you do, you still find that every player is in every room.
If you find yourself in this sort of situation, like I might have recently, then it’s almost certain that you have nested state, you are only making a shallow clone, and you should be using structuredClone
.
Shallow copies of nested states
Here’s a simple version of the issue I described above. We have a default state and when we generate a new room that state is cloned to the room. The room has a function to add a player to its state.
const defaultState = {
roomName: "",
players: []
}
class Room {
constructor(name) {
this.state = { ...defaultState, roomName: name }
}
addPlayer(playerName) {
this.state.players.push(playerName);
}
}
You can create a new room and add a player to it.
const room = new Room("room1");
room.addPlayer("Phil");
console.log(room.state);
// { roomName: "room1", players: ["Phil"] }
But if you try to create a second room, you’ll find the player is already in there.
const room2 = new Room("room2");
console.log(room2.state);
// { roomName: "room2", players: ["Phil"] }
It turns out the player even entered the default state.
console.log(defaultState);
// { roomName: "", players: ["Phil"] }
The issue is the clone of the default state that was made in the constructor. It uses object spread syntax (though it could have been using Object.assign
) to make a shallow clone of the default state object, but a shallow clone is only useful for cloning primitive values in the object. If you have values like arrays or objects, they aren’t cloned, the reference to the original object is cloned.
You can see this because the players
array in the above example is equal across the default state and the two rooms.
defaultState.players === room.state.players;
// true
defaultState.players === room2.state.players;
// true
room.state.players === room2.state.players;
// true
Since all these references point to the same object, whenever you make an update to any room’s players, all rooms and the default state will reflect that.
How to make a deep clone
There have been many ways in JavaScript over the years to make deep clones of objects, examples include Lodash’s cloneDeep
and using JSON
to stringify
and then parse
an object. However it turned out that the web platform already had an underlying algorithm to perform deep clones through APIs like postMessage
.
In 2015 it was suggested on a W3C mailing list that the algorithm was exposed publicly, though it took until late 2021 when Deno, Node.js and Firefox released support for structuredClone
, followed in 2022 by the other browsers.
If you want to make a deep clone of an object in JavaScript you should use structuredClone
.
Using structuredClone
Let’s see the function in action. If we update the Room
class from the example above to use structuredClone
, it looks like this:
class Room {
constructor(name) {
this.state = structuredClone(defaultState);
this.state.roomName = name;
}
addPlayer(playerName) {
this.state.players.push(playerName);
}
}
Creating one room acts as it did before:
const room = new Room("room1");
room.addPlayer("Phil");
console.log(room.state);
// { roomName: "room1", players: ["Phil"] }
But creating a second room now works as expected, the players are no longer shared.
const room2 = new Room("room2");
console.log(room2.state);
// { roomName: 'room2', players: [] }
And the players arrays are no longer equal references, but completely different objects:
defaultState.players === room.state.players;
// false
defaultState.players === room2.state.players;
// false
room.state.players === room2.state.players;
// false
It is worth reading how the structured clone algorithm works and what doesn’t work. For example, you cannot deep clone Function
objects or DOM nodes, but circular references are handled with no problem.
What a state
If you find yourself in a pickle when trying to clone your state, it may be because you are only making a shallow clone. If you have a simple state that is made up of primitives, then it is fine to use a shallow clone. Once you introduce nested objects that’s when you need to consider using structuredClone
to create a deep clone and avoid just copying references.
If you do find yourself facing this issue, I hope it takes you less time than it took me to realise what was going on.