Object Cloning in JavaScript (Deep vs. Shallow Copies) - A Comprehensive Guide
Introduction
In the world of JavaScript, understanding how data is copied and referenced is crucial for preventing bugs and ensuring the correct behavior of applications. One of the most common sources of confusion is the difference between deep and shallow copies.
What are the differences between a deep copy and a shallow copy in JavaScript? Let us find out in this article.
Bonus: At the end of this article, I will also share my recommendations based on different factors and situations.
Prerequisites
Basic JavaScript Knowledge
Familiarity with Objects and Arrays: An understanding of how objects and arrays work, including how to create, access, and modify their properties and elements.
Understanding Reference Values
If you know anything about working with primitive data types (Strings, Numbers, Booleans, etc.), your default response to cloning data is assigning it to a new variable. With primitive data types, this is the end of the story, as a brand new independent value gets created and stored in memory, with no ties to the original. Changes to this value do NOT affect the original, and vice versa.
That, unfortunately, is not the case with composite data types (Objects and Arrays). Let us compare their different behaviors:
Primitive Types
Take a look at the example below:
// Numbers
let num1 = 7
let num2 = num1
console.log(num2) // 7
num1 = 10
console.log(num1) // 10
console.log(num2) // 7
We assigned 7
to the variable num1
, assigned the value of num1
(i.e. 7
) to the variable num2
, then changed the value of num1
to 10
. When we log both variables, we observe that num1
has been updated to 10
, while num2 is still 7
. This is the default behavior of primitive types.
Next, let us create a function that modifies a primitive type:
// Strings
function modifyString(input) {
input += " --modified"
return input
}
let student1 = "Akin"
let student2 = modifyString(student1)
console.log(student1) // "Akin"
console.log(student2) // "Akin --modified"
Here, we've introduced a new function: modifyString
. This function takes a string as its parameter and returns a modified version of that string, marked with the "--modified"
flag. Initially, we assigned the value "Akin"
to student1
. Then, we passed student1
to the modifyString
function and assigned the result to student2
. The outcome is as follows: student1
remains unchanged as "Akin"
, while student2
receives the "--modified"
flag.
This is also consistent with the behavior of primitive types.
Reference Types
let num1 = [1, 2, 3, 4, 5, 6]
let num2 = num1
console.log(num2) // [1, 2, 3, 4, 5, 6]
num1[2] = 10
console.log(num1) // [1, 2, 10, 4, 5, 6]
console.log(num2) // [1, 2, 10, 4, 5, 6]
Notice how when we change the third element of num1
from 3
to 10
, it reflects on num2
also. This is unlike the behavior of the primitive types we have considered so far.
Next, let us create a function that modifies a reference type:
function modifyArray(input) {
input.push(99)
return input
}
let numList = [1, 2, 3, 4, 5, 6]
let numList_2 = modifyArray(numList)
console.log(numList) // [1, 2, 3, 4, 5, 6, 99]
console.log(numList_2) // [1, 2, 3, 4, 5, 6, 99]
Here, we've introduced a new function: modifyArray
. This function takes an array as its parameter and returns a modified version of that array, with the element 99
added to the end. Initially, we assigned the array [1, 2, 3, 4, 5, 6]
to numList
. Then, we passed numList
to the modifyArray
function and assigned the result to numList_2
. The outcome is as follows: both numList
and numList_2
get updated to [1, 2, 3, 4, 5, 6, 99]
.
You will encounter this behavior with arrays and objects in JavaScript. That is the default behavior in JavaScript, and it happens because arrays, functions, and objects are composite data types, which are also sometimes referred to as reference types. That means that when you reassign a reference type to a new variable, you are simply copying a reference to the same object in memory and not the object itself.
What if that is not what you want? What if the functionality of your program relies on the creation of unique and independent clones? Let us discuss your options.
Shallow Copies
Let us begin by understanding shallow copies. When you shallow copy an array or object in JavaScript, you create a new array or object, such that only the top-level structure is duplicated. All nested properties are still shared between the original and the copy.
Important definitions:
Top-level Element in Arrays*: An element that directly belongs to the array and is not nested within any other arrays or objects within it. e.g. elements*
1
,2
,3
, and7
in the array[1, 2, 3, [4, 5, 6], 7]
Top-level Element in Objects*: A property-value pair in an object that is directly defined within the object itself and is not nested within any other objects. e.g. properties*
a
andb
in the object{a: 1, b: 2, c: {x: 1, y: 2} }
Creating Shallow Copies
In JavaScript, there are three major means of creating shallow copies:
Spread operator (
...
)Object.assign()
Array.from()
What's all the talk without examples?
Spread Operator (
...
)const teamArr = ["John", "Will", ["Ali", "Angie", "Chike"]] const shallowTeamArr = [...teamArr] console.log(shallowTeamArr) // ["John", "Will", ["Ali", "Angie", "Chike"]] teamArr[1] = "Ben" teamArr[2][0] = "Kalu" console.log(teamArr) // ["John", "Ben", ["Kalu", "Angie", "Chike"]] console.log(shallowTeamArr) // ["John", "Will", ["Kalu", "Angie", "Chike"]]
In this example, I'm only updating the original array (
teamArr
), and, of course, changes at the top level work as expected. Only the name inteamArr
is changed (from"Will"
to"Ben"
). Its equivalent inshallowTeamArr
remains the same.Now, let's take a look at the nested array. When I swap out
"Ali"
for"Kalu"
inteamArr
, the change is reflected inshallowTeamArr
. This behavior sums up shallow copies in a nutshell.This behavior is also observed with objects, whether we use the spread operator (
...
) or JavaScript'sObject.assign
method. This is illustrated in the following examples:const userObj = { name: "John", car: { maker: "Audi", model: "R8" } } const shallowUserObj = {...userObj} console.log(shallowUserObj) /* { name: "John", car: { maker: "Audi", model: "R8" } } */ userObj.name = "Charlie" userObj.car.model = "RS6" console.log(userObj) /* { name: "Charlie", car: { maker: "Audi", model: "RS6" } } */ console.log(shallowUserObj) /* { name: "John", car: { maker: "Audi", model: "RS6" } } */
Object.assign()
const userObj = { name: "John", car: { maker: "Audi", model: "R8" } } const shallowUserObj = Object.assign({}, userObj) console.log(shallowUserObj) /* { name: "John", car: { maker: "Audi", model: "R8" } } */ userObj.name = "Charlie" userObj.car.model = "RS6" console.log(userObj) /* { name: "Charlie", car: { maker: "Audi", model: "RS6" } } */ console.log(shallowUserObj) /* { name: "John", car: { maker: "Audi", model: "RS6" } } */
Array.from()
const teamArr = ["John", "Will", ["Ali", "Angie", "Chike"]] const duplicatedTeamArr = Array.from(teamArr) console.log(duplicatedTeamArr) // logs ["John", "Will", ["Ali", "Angie", "Chike"]] teamArr[1] = "Ben" teamArr[2][0] = "Kalu" console.log(teamArr) // logs ["John", "Ben", ["Kalu", "Angie", "Chike"]] console.log(duplicatedTeamArr) // logs ["John", "Will", ["Kalu", "Angie", "Chike"]]
I'm sure you can already picture more than a few scenarios where this would be undesirable. So, how do we "fix" this behavior? Deep copies.
Deep Copies
When you perform a deep copy of an array or object in JavaScript, you create a new array or object in which every level of the object or array structure is duplicated*.* All properties are replicated and have no connection to the original, aside from containing the same values. You can envision this copy as a wholly independent version of the object with no shared properties, whether they are nested or not.
Creating Deep copies
When it comes to implementing deep copies from scratch, the recursive approach stands out as our preferred tool of choice.
Here are a few methods:
Recursive approach
JSON.parse()
+JSON.stringify()
structuredClone()
Lodash's
cloneDeep()
Let us try out some examples:
Recursive approach
function deepCopy(obj) { if (typeof obj !== 'object' || obj === null) { return obj; } if (Array.isArray(obj)) { return obj.map(deepCopy); } const newObj = {}; for (const key in obj) { newObj[key] = deepCopy(obj[key]); } return newObj; } /* ----------------- Examples ----------------- */ const userObj = { name: "John", car: { maker: "Audi", model: "R8" } } const deepCopiedUserObject = deepCopy(userObj) console.log(deepCopiedUserObject) /* { name: "John", car: { maker: "Audi", model: "R8" } } */ deepCopiedUserObject.name = "Charlie" deepCopiedUserObject.car.model = "RS6" console.log(userObj); /* { name: "John", car: { maker: "Audi", model: "R8" } } */ console.log(deepCopiedUserObject); /* { name: "Charlie", car: { maker: "Audi", model: "RS6" } } */
All done! Notice how only
deepCopiedUserObject
is updated in this example? Hisname
changes to "Charlie," and hiscar
model
changes to the"RS6"
, all while leavinguserObj
untouched and free from interference. This is the desired outcome –deepCopiedUserObject
is an independent clone.I could conclude this article here, but let us explore other options to better inform our decisions.
JSON.parse()
+JSON.stringify()
const userObj = { name: "John", car: { maker: "Audi", model: "R8" } } const jsonString = JSON.stringify(userObj) const deepCopiedUserObject = JSON.parse(jsonString) console.log(deepCopiedUserObject) /* { name: "John", car: { maker: "Audi", model: "R8" } } */ deepCopiedUserObject.name = "Charlie" deepCopiedUserObject.car.model = "RS6" console.log(userObj); /* { name: "John", car: { maker: "Audi", model: "R8" } } */ console.log(deepCopiedUserObject); /* { name: "Charlie", car: { maker: "Audi", model: "RS6" } } */
Works just as well, or does it? This method utilizes the
JSON.parse()
andJSON.stringify()
methods available on the built-inJSON
namespace to serialize and deserialize objects to and from JSON format. The underlying concept involves converting our object into a string (a non-reference data type), then transforming it back into an object and assigning it to a new variable.WARNING: This method is essentially a hack and is a less-than-perfect solution known to have several shortcomings, including:
Data Loss:
JSON.stringify()
cannot serialize certain JavaScript data types, such as functions,undefined
values,null
,Date()
, and objects with cyclic references. When such data types are encountered, they are either omitted or converted to null in the JSON string, leading to data loss.Loss of functionality
Performance issues: cloning with
JSON.parse(JSON.stringify())
can get very slow, especially as the object gets larger.
structuredClone()
const userObj = { name: "John", car: { maker: "Audi", model: "R8" } } const deepCopiedUserObject = structuredClone(userObj) console.log(deepCopiedUserObject) /* { name: "John", car: { maker: "Audi", model: "R8" } } */ deepCopiedUserObject.name = "Charlie" deepCopiedUserObject.car.model = "RS6" console.log(userObj); /* { name: "John", car: { maker: "Audi", model: "R8" } } */ console.log(deepCopiedUserObject); /* { name: "Charlie", car: { maker: "Audi", model: "RS6" } } */
This does the job perfectly! The
structuredClone()
method is part of the HTML Living Standard (HTML5) and allows you to create clones of structured data, including complex objects and cyclic structures.structuredClone()
is designed to handle complex objects, including DOM elements and custom classes, and creates deep copies by default.It has the added advantage of being a built-in JavaScript API that is present in all modern browsers.
Lodash's
cloneDeep()
const _ = require("lodash") /* ----------------- Examples ----------------- */ const userObj = { name: "John", car: { maker: "Audi", model: "R8" } } const deepCopiedUserObject = _.cloneDeep(userObj) console.log(deepCopiedUserObject) /* { name: "John", car: { maker: "Audi", model: "R8" } } */ deepCopiedUserObject.name = "Charlie" deepCopiedUserObject.car.model = "RS6" console.log(userObj); /* { name: "John", car: { maker: "Audi", model: "R8" } } */ console.log(deepCopiedUserObject); /* { name: "Charlie", car: { maker: "Audi", model: "RS6" } } */
While not a built-in JavaScript API, the Lodash library provides a
cloneDeep()
function that creates deep copies of objects and arrays. It is optimized to handle even the most complex data structures reliably and efficiently.
My recommendation
You will encounter situations where a shallow copy is sufficient. When you do, stick with the spread operator (...
). When you find you need a deep copy though, structuredClone()
is your best bet. If your project already has Lodash though, you might want to pull out the cloneDeep()
method, as it is even more performant than the already efficient structuredClone()
.
Wrapping up
In conclusion, understanding the nuances of deep and shallow copying in JavaScript is a pivotal skill for any developer. When you find yourself needing to preserve the integrity of unique object states or history, deep copying is your ally. For instance, consider a version control system or undo functionality in an application, where deep copying ensures each version remains distinct.
On the other hand, when optimizing performance and memory is paramount or when maintaining relationships between large data structures is necessary, shallow copying shines. Imagine a complex hierarchical menu system or a state management system in a frontend framework, where shallow copying maintains references and ensures efficient updates.
By mastering both techniques, you empower yourself to choose the right tool for the job, making your code more efficient, maintainable, and resilient.
Happy hacking!