Mutable Objects in JavaScript

July 29, 2020

In JavaScript, Objects and Arrays are mutable data structures, and constants and variables are references. I tripped over this recently working on state update logic in a React app. In the app, a higher-level component contained a large array of objects. A child component acted as a cache, copying one of the objects to its own state upon its first render. I carelessly set the cache's state like so:

const cacheInitialState = higherLevelComponentState[0];

, thereby creating a new reference to an object instead of copying the object. This resulted in a bug where state updated unexpectedly all over the place.

An illustration showing the relation between a higher-level component, which contains three objects, and a child component, which contains a copy of one of the three objects.

Not sure what I'm talking about? Great! I invite you to read on and check out mutability in JavaScript with me. I give examples, and I suggest you code along using your browser's debugging console -- to open the Firefox console, press either Ctrl + Shift + k or Cmd + Shfit + k (on Mac). If reading the summary doesn't leave you with a question, you probably won't learn anything form this article.

Objects

The Object object is a key-value store, where each key: value pair is called a member of the Object.

const rob = {"name": "Rob", "age": 2, "isGoodBoy": true};
Object.keys(rob);  // ["name", "age", "isGoodBoy"]
Object.values(rob);  // ["Rob", 2, true]

Here, Object.keys() and Object.values() are JavaScript built-in methods of the Object class. They return the keys and values of an object. I can access a value via its key. The two notations below do the same:

rob.name;  // "Rob"
rob.["name"];  // "Rob"

Mutability

Since Objects are mutable data structures, I can add new key-value pairs to rob:

rob["graduatedDogSchool"] = true;
rob["graduatedDogSchool"];  // true

Handy, right? Let me go ahead and create another dog named Bob. To save some time, I'll base bob on rob:

const bob = rob;
bob.name = "Bob";
bob.age = 5;

Okay, let's see how the dogs compare:

for (let key in bob) {
  console.log(`Are values referenced by key ${key} equal?`);
  console.log(bob[key] === rob[key])  // true, true, true;
}

The key-value pairs of the two dogs are identical. Why? Because Rob and Bob are the same object:

Object.is(rob, bob);  // true

What's happening?

I made a mistake when I "created" another dog. All const, var and let do is initialise references to objects. In the case of const, that reference cannot be re-declared or re-assigned:

const x = {1: 'a', 2: 'b'};
x = 'hello';  // TypeError: invalid assignment to const 'x'
const x = 'hello';  // SyntaxError: redeclaration of const x

However, the Object that x refers to is mutable!

x[3] = 'c';
x[3];  // 'c'

When I declare const bob = rob, I don't create a new object -- I merely declare a new reference to the same object that rob already refers to.

How to Define a New Dog Then?

I need to clone the original object to really create a new object. Here are two equivalent ways to do this:

var bob = Object.assign({}, rob);
var bob = {...rob};

Cumbersome, isn't it? You haven't seen the worst part yet. Let's say Rob and Bob both have nicknames. We can then define an object that identifies them with their respective nickname.

const dogNicknames = {'Beans': rob, 'Mr Snuffels': bob};

Let me clone that object:

var dogNicknamesClone = {...dogNicknames};
Object.is(dogNicknamesClone, dogNicknames);  // false
Object.is(dogNicknamesClone.rob, dogNicknames.rob);  // true

The keys of dogNicknamesClone are themselves only references to Rob and Bob. By cloning dogNicknames, I clone the reference to Rob and Bob, but not the objects.

Deep Clones

JavaScript behaves this way to save resources: Clones are costly both in terms of memory and compute time. Cloning nested objects is especially costly -- these kinds of clones are called deep clones, and deep cloning isn't properly supported by your browser at the time writing. The MDN web docs suggest that the best we can do is convert the object to a JSON string and then parse it back to an Object:

dogNicknamesClone = JSON.parse(JSON.stringify(dogNicknames);
Object.is(dogNicknamesClone.bob, dogNicknames.bob);  // false

While both parse and stringify methods are highly optimised for performance, this approach won't work for object values of type Date, Function or RegExp. You'll have to either implement your own cloning-solution, or use a third party package, such as clone-deep.

Long story short: To correctly assign the cache component's initial state, I should have written the following line of code instead:

const cacheInitialState = JSON.parse(JSON.stringify(higherLevelComponentState[0]));

That's all I want to share for now. I hope this was an interesting read. If you have any suggestions, critique, questions or just want to chat about the topic, feel free to reach out to me via email.


Profile picture

By Philipp Jung, a data engineer with one foot still in academia. Follow me on mastodon.world/@pljung, or reach out on LinkedIn.