DEV Community

Joe Erickson
Joe Erickson

Posted on • Originally published at jerickson.net

Why does changing my copy change the original! A JavaScript Dilemma

Have you ever been working on your JavaScript code and changing the value of an object you were sure that you made a safe copy of only to have the original object change too?

And then you looked up why the heck that was happening, you read a blog post saying "Use Object.assign() instead". But you do that and it still freaken happens?

Then someone says that you have to download lodash and use the cloneDeep() method? And still no one has explained just what the hell is going on?

Yeah, I hate that.

Anyway, here's why it's happening.

Want a YouTube version of this article? Watch it here:

The values of variables

This is probably some fairly common code that you've seen before if you've done any kind of JavaScript coding:

let counter = 2;
let peopleArray = ['John'];
let languages = {
    en: 'English',
    fr: 'French',
    zh: 'Chinese'
};
Enter fullscreen mode Exit fullscreen mode

These are all common ways of declaring a variable and defining what its value should be. You have a counter set to the number 2, a peopleArray set to an array with one string in it, 'John', and languages set to an object with keys of en, fr, and zh with the values 'English', 'French', and 'Chinese', respectively.

I also think that I can safely assume that you know that if you call this code:

peopleArray.push('Marty');
console.log(peopleArray);
Enter fullscreen mode Exit fullscreen mode

You will see the following output in the console:

[ 'John', 'Marty' ]
Enter fullscreen mode Exit fullscreen mode

And a call to:

counter++;
console.log(counter);
Enter fullscreen mode Exit fullscreen mode

Will give you:

3
Enter fullscreen mode Exit fullscreen mode

All of that is what we would expect. Nice, normal, not crazy making behavior from our good friend JS.

Where we run into problems is with this:

let supportedLanguages = languages;
supportedLanguages.de = 'German';

console.log(languages);
Enter fullscreen mode Exit fullscreen mode

Which gives us this clearly wrong answer:

{ en: 'English', fr: 'French', zh: 'Chinese', de: 'German' }
Enter fullscreen mode Exit fullscreen mode

But I didn't add German to the languages object! I added it to the new supportedLanguages object! Ah!

Why did this happen? How can we anticipate it and prevent it and, just as important, talk to other developers about it in the future?

Well, let me tell you.

What exactly is in a JavaScript variable?

When we think about JavaScript variables, what lives in those variables? How you think about this can help us understand the issue that we're seeing.

Most of the time, we probably don't think about this. Or we think that what's on the right side of the = is what lives in it. But that's only sort of true.

Here's how I want you to think about JavaScript variables from now on.

JavaScript variables only hold one thing.

That makes sense on the surface. Of course they only hold one thing.

But arrays and objects hold more than one thing, of course. Surely, I don't mean those?

Oh, but I do! Allow me to explain.

Many of the data types in JavaScript represent one thing. Like numbers and booleans. Another type can be treated in this same category---because of the way that it's programmed in JavaScript---strings. So you can consider that when you put one of these pieces of data in a variable, that's what the variable has in it.

let counter = 1;
let shouldContinue = true;
let name = 'Marty';
Enter fullscreen mode Exit fullscreen mode

Here, the variable counter contains the value of 1. If we set a new value, we are replacing that value:

counter = 1;
Enter fullscreen mode Exit fullscreen mode

If we are copying the value to another variable, it is indeed copying it and not doing something we don't expect:

let extraCounter = counter; // Copies the number 1 from counter to extraCounter
let oldShouldContinue = shouldContinue; // Copies true from shouldContinue to oldShouldContinue
let originalName = name; // Also puts 'Marty' from name to originalName
Enter fullscreen mode Exit fullscreen mode

If you keep this mental model1 for numbers, booleans, and strings, you'll be just fine. It's probably the one you're expecting anyway.

Object and Array variables are different

While the above works when thinking about numbers, booleans, and strings, it doesn't work when thinking about object and array variables. That's because objects and arrays hold more than one thing.

And since they contain more than one thing, they can't fit into a variable. So...what's in those variable?

Leave your number at the tone

Imagine, if you will, your phone's address book. You've got a lot of entries in there and if you scroll through, you'll see all the names of the people that you know in there. If you click on one of those names, does that person spring out of your phone?

Of course not! Phones don't hold people! But they can hold numbers. And that number acts as a link between you and that person. If you call that number, you can then talk to the actual person.

Well, that's how objects and arrays work in JavaScript too! What's stored in the variable? An address to the object or array!

let person = { name: 'Anna', occupation: 'Developer' };
Enter fullscreen mode Exit fullscreen mode

So what does person contain? You can think of it as the address to the object on the right side, which is also called the reference in programming circles.

let person = { name: 'Anna', occupation: 'Developer' };
// person contains something like an address that points to the object,
// but doesn't actually contain the object.
Enter fullscreen mode Exit fullscreen mode

It's like a phone number for data! When the variable is used, it is calling the object and asking the object to do something:

console.log(person.name);
//                ^--- ring, ring, can I have your name?
Enter fullscreen mode Exit fullscreen mode

The . is often called the dereference operator for this very reason. It dereferences, or calls, the object.

This address business is something that JavaScript hides behind the scenes and you will never see it, except in a case like this:

let person = { name: 'Anna', occupation: 'Developer' };
let aNewPerson = person; // We copied the address, not the object!
Enter fullscreen mode Exit fullscreen mode

In the above example, person contains an address and when aNewPerson "copies" person, it's actually copying the address, not the object! It's like having two people in your contacts that have the same phone number. When you call them, you'll connect with the same person on the other end, no matter what you change the names to.

So this is why, if we change the object aNewPerson points to, it'll also change the object person is pointing to!

let person = { name: 'Anna', occupation: 'Developer' };
let aNewPerson = person; // We copied the address, not the object!

aNewPerson.name = 'Marie';
console.log(person);
Enter fullscreen mode Exit fullscreen mode

Can you guess what this prints?

{ name: 'Marie', occupation: 'Developer' }
Enter fullscreen mode Exit fullscreen mode

And the same is true of arrays:

let names = [ 'John' ];
let copyOfNames = names;
// That only copied the address to the array, it did not copy the array!

copyOfNames.push('Marty');
console.log(names);
Enter fullscreen mode Exit fullscreen mode

Will show:

[ 'John', 'Marty' ]
Enter fullscreen mode Exit fullscreen mode

It was the same array all along!

Solutions to copy

Object.assign()

So, now that we know objects and arrays copy references and not values, how do we avoid the worst of the mistakes when working with them?

The first is to just keep in mind that = will copy the address and then any changes will happen to the object that they both point to. Usually, that's not what you want.

So the first thing to look at for objects only is the Object.assign() function. This does what is called a shallow copy, meaning any direct member is copied. So if you have a simple, flat object, this should work fine:

let myPhone = {
    manufacturer: 'Motorola',
    speed: 'LTE'
};
let yourPhone = Object.assign({}, myPhone);

yourPhone.manufacturer = 'Apple';
console.log(myPhone);
console.log(yourPhone);
Enter fullscreen mode Exit fullscreen mode

This will create a new object---the {} above as the first argument to Object.assign()---and then copies the values of the second argument---myPhone in this case---to that new object. We got this output:

{ manufacturer: 'Motorola', speed: 'LTE' } // myPhone
{ manufacturer: 'Apple', speed: 'LTE' } // yourPhone
Enter fullscreen mode Exit fullscreen mode

If you have simple data like this, this will work great. But it doesn't do a deep copy. A deep copy is where all the values, no matter how deep in the structure, are copied to the new object. In the case above with Object.assign(), it only copies the first level and that means that any objects at that level will have their references copied instead!

let goodBook = {
    author: {
        first_name: 'Brené',
        last_name: 'Brown'
    },
    title: 'Daring Greatly'
};

let scaryBook = Object.assign({}, goodBook);
scaryBook.title = 'The Shining';
scaryBook.author.first_name = 'Steven';
scaryBook.author.last_name = 'King';

console.log(goodBook);
Enter fullscreen mode Exit fullscreen mode

What does that print? Shock of shocks!

{
  author: { first_name: 'Steven', last_name: 'King' },
  title: 'Daring Greatly'
}
Enter fullscreen mode Exit fullscreen mode

Does it make sense yet why that would happen?

If Object.assign() is only copying the first level, that means that it copied goodBook.title and put the value in the new object. But when it copied goodBook.author it took the reference to the author object and copied it. So both books are stuck with the same author and changing it in one changes it in the other! This is why you can't always use Object.assign().

So the rule of thumb is:

If the object you are copying does not contain an object or array, use Object.assign().

slice()

slice() is often recommended to copy arrays. Suffice it to say, it has the same problems with Object.assign().

let books = [
    'The Alchemist',
    'A Tale of Two Cities',
    {
        title: 'Fight Club'
    }
];

let happyBooks = books.slice();
happyBooks[1] = 'The Mystery of the Ghostly Face'; // This won't change books
happyBooks[2].title = 'The Cat in the Hat'; // But this will because of the object

console.log(books);
Enter fullscreen mode Exit fullscreen mode

So, again like Object.assign():

If the array you are copying doesn't contain an object or array , use slice().

_.cloneDeep()

How do you make sure you actually get a copy? Sadly, the answer lies outside of JavaScript and in other libraries that you will need to import. There is no native function in JavaScript that can do this. You could write your own function to make deep copies, but there are functions already written---and tested---that we can use if we import them into our project.

One of the most popular is cloneDeep() from the lodash library.

If your object or array is any kind of complex, use cloneDeep() or something similar from a library your are already using in your project.


  1. A mental model is a way of thinking about a programming concept, not necessarily the way that it's actually programmed. It helps you picture in your head how things are working so that you can reason about and problem solve with them. 

Top comments (1)

Collapse
 
_genjudev profile image
Larson

Just a little helper function:

const cp = obj => {
    return typeof obj === "object" ?
        Array.isArray(obj) ? 
            [...obj] 
            : Object.assign({}, obj) 
        : obj
}

const copyOfList = cp(list);
Enter fullscreen mode Exit fullscreen mode