In the latest episode of, “I Have No Idea What I’m Doing” I learned that everything I thought I knew about immutability in Javascript was a lie.
Okay, so I’m being dramatic. Not everything was a lie. But a fundamental part of my understanding was incorrect. After speaking to a few people about the issue, it seems this was a shared misconception.
This all stems from a subtle, yet fundamental difference in how we copy objects in javascript. Deep copying and shallow copying.
Deep copying is what we want for true immutable data. It is a copy of all the values of an object, and all the values of all the objects within it. Shallow copying—on the other hand—is a copy of all the values of an object, with references to all the objects within it. This is what tripped me up.
To understand the problem, we need to look at three ways of copying objects.
Okay, so let’s strip this all the way back. Let’s create a mutable reference to an object.
const initialObject = {
name: "Sam",
twitter: "@samdbeckham",
};
const newObject = initialObject;
This is bad for immutability because any changes to newObject
reflect in initialObject
like so:
newObject.twitter = "@frontendne";
console.log(initialObject.twitter); // @frontendne
In this example, newObject
is a reference to initialObject
.
So whenever we get or set data on either of these objects it is also applied to the other object.
This is useful in a lot of different ways, but not great for immutability.
This is the most common form of copying data in an immutable manner.
We utilise the spread operator to create a copy of initialObject
.
If you’ve used redux before, you’ll have seen this inside your reducers.
const initialObject = {
name: "Sam",
twitter: "@samdbeckham",
};
const newObject = {
...initialObject,
};
It’s a subtle change, but the ...
makes all the difference.
newObject
is no-longer linked to initialObject
.
It’s now a copy of the data and an entirely new object.
So if we make the same change we did earlier, we get the following result:
newObject.twitter = "@frontendne";
console.log(initialObject.twitter); // @samdbeckham
console.log(newObject.twitter); // @frontendne
Modifying the data on newObject
doesn’t affect initialObject
anymore.
We can go about our day, modifying newObject
and initialObject
remains clean.
But this is a shallow copy, and the immutability is only one level deep.
To show this, we need an object inside our initialObject
:
const initialObject = {
name: "Sam",
social: {
twitter: "@samdbeckham",
youtube: "frontendne",
},
};
const newObject = {
...initialObject,
};
At first glance, this newObject
looks like an immutable copy of initialObject
but look what happens when we do this:
newObject.social.twitter = "@frontendne";
console.log(initialObject.social.twitter); // @frontendne
Sadly, the immutability is only skin deep.
As soon as we go down another level, we’re back to referencing values.
If we were to open up newObject
, it would look a bit like this:
const newObject = {
name: "Sam",
social: initialObject.social,
};
We can get around this issue by shallow copying one level deeper and defining newObject
like so:
const newObject = {
...initialObject,
social: {
...initialObject.social,
},
};
This is how it’s usually dealt with in redux, but it only adds one more level of immutability. If there are any other nested objects they will still be stored as references. You can see how (with certain data structures) this could get messy.
_Note: Object.assign()
and Object.freeze()
have the same shallow copying issues as spread. _
Finally, we get to deep copying. Deep copying offers us true object immutability. We can change any value in an object—no matter how deeply nested it is—and it won’t mutate the data we copied it from.
const initialObject = {
name: "Sam",
social: {
twitter: "@samdbeckham",
youtube: "frontendne",
},
};
const newObject = deepCopy(initialObject);
newObject.social.twitter = "@frontendne";
console.log(initialObject.social.twitter); // @samdbeckham
console.log(newObject.social.twitter); // @frontendne
Hooray! We’re immutable!
Unfortunately, Javascript doesn’t have a function called deepCopy()
so we’ve had to make our own; and it isn’t pretty.
There’s no “nice” way to handle deep copying in Javascript.
Das Surma wrote a article on deep copy which has a few good examples, here are some of the simpler ones.
This is the most concise and easy to understand method, and it looks like this:
const deepCopy = (object) => JSON.parse(JSON.stringify(object));
First we turn the object into a JSON string with JSON.stringify()
then we convert that string back into an object with JSON.parse()
.
Stringifying the data throws out all references, making the returned object completely immutable.
But, if there are any references we need to keep inside this object, they’re gone.
If we have any maps, regex, dates, or other special types; they’re gone.
If we have any cyclic objects inside the object (which we shouldn’t) the whole thing breaks and throws an error.
So it’s not very robust.
If you don’t want want to deal with the issues the JSON parser brings, there are a few—albeit hacky—methods you can can use. These all revolve around pasing our data to a service, then querying that service to pull our cleaned data back out. It’s like money laundering, only with data, and nowhere near as cool.
For example, we can utilise the notification API:
const deepCopy = (object) =>
new Notification("", {
data: object,
silent: true,
}).data;
This triggers a notification, silences it, then returns the data from that notification. Unfortunately, the user needs to be able to receive notifications for this to work.
We can also utilise the history API and the messageChannel
in similar ways.
But they all have their downsides.
Deep copying is a bit of a heavy-handed approach to immutability. Being aware of the gotchas of shallow copying should be enough to see you through most problems. You can use the nested spread method outlined above to fix any problem areas. If this approach is starting to get unwieldy, you should aim to improve your data structure first.
If you absolutely need deep copying, then fear not.
There’s an issue on the HTML spec that hopes to address this, with the introduction of structuredClone()
.
The more visibility this gets, the more likely it is to be implemented.
Until then, I’d suggest using a library like Immutable.js to handle your immutability.
Or grab the cloneDeep()
helper from the underscore library for a quick-fix.
If you’re up for a challenge, try coming up with your own solution to deepCopy. My friend Niall had a lot of fun playing with some ideas on Twitter. I’d be interested to see what you all come up with.
Comments
(Powered by webmentions)Start the Conversation