The idea of writing immutable JavaScript is becoming more and more popular. Primitive data types in JavaScript are immutable by default. Arrays and objects are not. This tutorial will show you how to replace mutable operations with arrays and objects with their immutable alternatives.
Arrays
Arrays are one of the most frequently used data structures in JavaScript. There are many built-in methods we can use when we work with them. The problem, when it comes to immutability, is that many of these methods are mutable by nature. Using these methods means changing the original data.
These mutable methods are push()
, pop()
, splice()
, shift()
, unshift()
, reverse()
and sort()
. Fortunately, there are alternatives we can use to replace these methods if we want to keep our JavaScript code immutable. Let's take a look at them.
Push
The push()
method allows us to add a new item at the end of existing array. We can achieve the same result while keeping our data immutable using spread syntax. All we have to do is to create new empty array, spread the original, and add any item we want to add. If we want to add multiple, we can.
// Mutable way:
const mutableArray = ['Apple', 'Netflix', 'Microsoft']
// Add item at the end:
mutableArray.push('Amazon', 'Uber')
console.log(mutableArray)
// Output:
// [ 'Apple', 'Netflix', 'Microsoft', 'Amazon', 'Uber' ]
// Immutable way:
const immutableArray = ['Apple', 'Netflix', 'Microsoft']
// Add item at the end:
const newArray = [...immutableArray, 'Amazon', 'Uber']
console.log(immutableArray)
// Output:
// [ 'Apple', 'Netflix', 'Microsoft' ]
console.log(newArray)
// Output:
// [ 'Apple', 'Netflix', 'Microsoft', 'Amazon', 'Uber' ]
Unshift
Similar method to push()
is unshift()
. The difference between these two is that instead of adding new item at the end of the array, unshift()
adds the item at the beginning. It inserts the item as the first. The immutable approach is similar to push()
, except that we have to reverse the order of spread and new items.
// Mutable way:
const mutableArray = ['Apple', 'Netflix', 'Microsoft']
// Add item at the beginning:
mutableArray.unshift('Amazon', 'Uber')
console.log(mutableArray)
// Output:
// [ 'Amazon', 'Uber', 'Apple', 'Netflix', 'Microsoft' ]
// Immutable way:
const immutableArray = ['Apple', 'Netflix', 'Microsoft']
// Add item at the beginning:
const newArray = ['Amazon', 'Uber', ...immutableArray]
console.log(immutableArray)
// Output:
// [ 'Apple', 'Netflix', 'Microsoft' ]
console.log(newArray)
// Output:
// [ 'Amazon', 'Uber', 'Apple', 'Netflix', 'Microsoft' ]
Pop
The pop()
method does two things. First, it removes the last item from an array. Second, it returns the removed item. When it removes the item it changes the original array. This happens even if you try to assign the result of this operation to a variable. We can do both in immutable fashion.
When we want to get the last element of an array, we can use indices. We take the length property of an array, subtract 1 and the result is the last item. If we also want to get the array, any items that precede the last, we can use slice()
method.
// Mutable way:
const mutableArray = ['Apple', 'Netflix', 'Microsoft']
// Get the last item:
const lastItem = mutableArray.pop()
console.log(lastItem)
// Output:
// 'Microsoft'
console.log(mutableArray)
// Output:
// [ 'Apple', 'Netflix' ]
// Immutable way:
const immutableArray = ['Apple', 'Netflix', 'Microsoft']
// Get the last item:
const lastItem = immutableArray[immutableArray.length - 1]
// Get the rest of the array:
const restOfArray = immutableArray.slice(0, immutableArray.length - 1)
console.log(immutableArray)
// Output:
// ['Apple', 'Netflix', 'Microsoft']
console.log(lastItem)
// Output:
// 'Microsoft'
console.log(restOfArray)
// Output:
// [ 'Apple', 'Netflix' ]
Shift
A reversed alternative to pop()
is shift()
. This method also removes an item from an array, but it removes it from the beginning. It also changes the original and returns the removed item. Immutable alternative is similar to pop()
. The difference here is two-fold.
First, to get the first item in the array we can use 0
as the index. For slice()
, and getting the rest of an array, we can say that we want everything except the first item.
// Mutable way:
const mutableArray = ['Apple', 'Netflix', 'Microsoft']
// Get the first item:
const firstItem = mutableArray.shift()
console.log(firstItem)
// Output:
// 'Apple'
console.log(mutableArray)
// Output:
// [ 'Netflix', 'Microsoft' ]
// Immutable way:
const immutableArray = ['Apple', 'Netflix', 'Microsoft']
// Get the first item:
const firstItem = immutableArray[0]
// Get the rest of the array:
const restOfArray = immutableArray.slice(1)
console.log(immutableArray)
// Output:
// ['Apple', 'Netflix', 'Microsoft']
console.log(firstItem)
// Output:
// 'Apple'
console.log(restOfArray)
// Output:
// [ 'Netflix', 'Microsoft' ]
Splice
The splice()
method is handy when we want to add, remove or replace items in/from an array. We can achieve the same in immutable fashion using combination of spread
syntax and slice()
. First, we create a new array. Next, we use spread
to copy the original. After that, we use slice()
to keep what we want.
// Mutable way:
const mutableArray = ['Apple', 'Netflix', 'Microsoft']
// Replace the 2nd item with two new items:
mutableArray.splice(1, 1, 'Uber', 'Amazon')
console.log(mutableArray)
// Output:
// [ 'Apple', 'Uber', 'Amazon', 'Microsoft' ]
// Immutable way:
const immutableArray = ['Apple', 'Netflix', 'Microsoft']
// Replace the 2nd item with two new items:
const newArray = [
...immutableArray.slice(0, 1),
...['Uber', 'Amazon'],
...immutableArray.slice(2)
]
console.log(immutableArray)
// Output:
// ['Apple', 'Netflix', 'Microsoft']
console.log(newArray)
// Output:
// [ 'Apple', 'Uber', 'Amazon', 'Microsoft' ]
Sort
The sort()
method makes it very easy to sort any array. By default, it sorts item in an ascending order. However, we can also provide custom sorting function to sort the array in any way we want. If we want to sort some array while keeping it immutable, we don't have to re-invent the wheel.
We can still use the sort()
method, but in combination with spread
syntax. The spread
syntax will help us copy the original array. We can then take the copy and sort it in any way we want. This change will leave the original array untouched.
// Mutable way:
const mutableArray = ['Microsoft', 'Apple', 'Netflix']
// Sort the array:
mutableArray.sort()
console.log(mutableArray)
// Output:
// [ 'Apple', 'Microsoft', 'Netflix' ]
// Immutable way:
const immutableArray = ['Microsoft', 'Apple', 'Netflix']
// Sort the array:
const newArray = [...immutableArray].sort()
console.log(immutableArray)
// Output:
// [ 'Microsoft', 'Apple', 'Netflix' ]
console.log(newArray)
// Output:
// [ 'Apple', 'Microsoft', 'Netflix' ]
Reverse
The reverse()
is an alternative to sort()
that helps reverse the order of items in an array. Just like the sort()
, it does so by changing the original array. When we combine this method with spread
syntax, we can create a copy of the array and apply reverse() to the copy, leaving the original untouched.
// Mutable way:
const mutableArray = ['Apple', 'Microsoft', 'Netflix', 'Amazon', 'Uber']
// Reverse the array:
mutableArray.reverse()
console.log(mutableArray)
// Output:
// [ 'Uber', 'Amazon', 'Netflix', 'Microsoft', 'Apple' ]
// Immutable way:
const immutableArray = ['Apple', 'Microsoft', 'Netflix', 'Amazon', 'Uber']
// Reverse the array:
const newArray = [...immutableArray].reverse()
console.log(immutableArray)
// Output:
// [ 'Apple', 'Microsoft', 'Netflix', 'Amazon', 'Uber' ]
console.log(newArray)
// Output:
// [ 'Uber', 'Amazon', 'Netflix', 'Microsoft', 'Apple' ]
Objects
Objects are just as popular in JavaScript as arrays, if not even more. Just like arrays, objects are also by default mutable. When we create an object, we can add new properties or remove existing at any time. There are ways we can ensure this never happens by freezing or sealing objects.
However, what if we actually want to change an object, add or remove properties, in immutable way? We can do both.
Adding properties
When we want to add properties while keeping our objects immutable we can use the spread
syntax. With spread
, we can create a clone of an object and spread it into a new object. Then, we can add any addition properties we want.
// Mutable way:
const person = {
firstName: 'Lori',
lastName: 'Robinson',
email: 'lori.robinson@example.com',
}
// Add properties:
person.birthday = '3/2/1993'
person.phoneNumber = '(094)-230-2145'
console.log(person)
// Output:
// {
// firstName: 'Lori',
// lastName: 'Robinson',
// email: 'lori.robinson@example.com',
// birthday: '3/2/1993',
// phoneNumber: '(094)-230-2145'
// }
// Immutable way:
const person = {
firstName: 'Lori',
lastName: 'Robinson',
email: 'lori.robinson@example.com',
}
// Add properties:
const newPerson = {
...person,
birthday: '3/2/1993',
phoneNumber: '(094)-230-2145',
}
console.log(person)
// Output:
// {
// firstName: 'Lori',
// lastName: 'Robinson',
// email: 'lori.robinson@example.com'
// }
console.log(newPerson)
// Output:
// {
// firstName: 'Lori',
// lastName: 'Robinson',
// email: 'lori.robinson@example.com',
// birthday: '3/2/1993',
// phoneNumber: '(094)-230-2145'
// }
Modifying existing property values
We can use the same approach also when we want to change existing property values. First, we create a new object. Next, we spread the original object into the new object. Finally, we add any key-value pairs we want to change. When some property already exists, its value will be overwritten by the new value.
// Mutable way:
const person = {
firstName: 'Lori',
lastName: 'Robinson',
email: 'lori.robinson@example.com',
phoneNumber: '(476)-632-5186',
}
// Add properties:
person.firstName = 'Nicholas'
person.lastName = 'Clark'
person.email = 'nicholas.clark@example.com'
console.log(person)
// Output:
// {
// firstName: 'Nicholas',
// lastName: 'Clark',
// email: 'nicholas.clark@example.com'
// phoneNumber: '(476)-632-5186'
// }
// Immutable way:
const person = {
firstName: 'Lori',
lastName: 'Robinson',
email: 'lori.robinson@example.com',
phoneNumber: '(476)-632-5186',
}
// Add properties:
const newPerson = {
...person,
firstName: 'Nicholas',
lastName: 'Clark',
email: 'nicholas.clark@example.com',
}
console.log(person)
// Output:
// {
// firstName: 'Lori',
// lastName: 'Robinson',
// email: 'lori.robinson@example.com',
// phoneNumber: '(476)-632-5186'
// }
console.log(newPerson)
// Output:
// {
// firstName: 'Nicholas',
// lastName: 'Clark',
// email: 'nicholas.clark@example.com',
// phoneNumber: '(476)-632-5186'
// }
Removing properties
When we want to remove some object property, one option that will do the job is the delete operator. We can do the same in an immutable way using destructuring assignment and spread
syntax. With destructuring assignment, we can extract object properties one by one.
After that, we can use the spread
syntax to get an object that contains the rest of properties that remained.
// Mutable way:
const person = {
firstName: 'Lori',
lastName: 'Robinson',
email: 'lori.robinson@example.com',
phoneNumber: '(476)-632-5186',
}
// Remove properties
delete person.email
delete person.phoneNumber
console.log(person)
// Output:
// {
// firstName: 'Lori',
// lastName: 'Robinson'
// }
// Immutable way:
const person = {
firstName: 'Lori',
lastName: 'Robinson',
email: 'lori.robinson@example.com',
phoneNumber: '(476)-632-5186',
}
// Add properties:
const { email, phoneNumber, ...newPerson } = person
console.log(person)
// Output:
// {
// firstName: 'Lori',
// lastName: 'Robinson',
// email: 'lori.robinson@example.com',
// phoneNumber: '(476)-632-5186'
// }
console.log(newPerson)
// Output:
// {
// firstName: 'Lori',
// lastName: 'Robinson'
// }
console.log(email)
// Output:
// 'lori.robinson@example.com'
console.log(phoneNumber)
// Output:
// '(476)-632-5186'
Working with nested structures, pt1
Previous solutions work well with simple objects that doesn't use nesting, or doesn't contain nested objects or arrays. When we have to deal with these use cases, we can still use spread
syntax. However, we have to remember that we must use spread on objects or arrays on all levels of nesting.
If we forget this, we will create deep copy only of the top level object. Any nested objects will remain shallow copies. This means that changing those nested objects will lead to changing the originals.
// Create more complex object by adding array as a value:
const person = {
firstName: 'Lori',
lastName: 'Robinson',
email: 'lori.robinson@example.com',
phoneNumber: '(476)-632-5186',
hobbies: ['gardening', 'reading', 'music'],
}
// This will not work:
const newPerson = { ...person }
// Try to add new hobby only to new object:
newPerson.hobbies.push('skiing')
// Note: this will change the original
// hobbies array as well
console.log(person)
// Output:
// {
// firstName: 'Lori',
// lastName: 'Robinson',
// email: 'lori.robinson@example.com',
// phoneNumber: '(476)-632-5186',
// hobbies: [ 'gardening', 'reading', 'music', 'skiing' ]
// }
console.log(newPerson)
// Output:
// {
// firstName: 'Lori',
// lastName: 'Robinson',
// email: 'lori.robinson@example.com',
// phoneNumber: '(476)-632-5186',
// hobbies: [ 'gardening', 'reading', 'music', 'skiing' ]
// }
When we use spread on all levels the problem in the previous example disappears. This also applies to nested object literals. When some object contains object literals, we have to spread them individually, just like arrays. This will ensure we are working with deep copies, not just shallow, the originals.
// Create more complex object:
const person = {
firstName: 'Lori',
lastName: 'Robinson',
email: 'lori.robinson@example.com',
phoneNumber: '(476)-632-5186',
hobbies: ['gardening', 'reading', 'music'],
family: {
firstName: 'Tobias',
lastName: 'Robinson',
relationship: 'brother',
}
}
// This will work:
const newPerson = {
...person,
hobbies: [...person.hobbies], // Spread the array as well
family: { ...person.family } // Spread the object as well
}
newPerson.hobbies.push('skiing')
newPerson.family.relationship = 'stepbrother'
console.log(person)
// Output:
// {
// firstName: 'Lori',
// lastName: 'Robinson',
// email: 'lori.robinson@example.com',
// phoneNumber: '(476)-632-5186',
// hobbies: [ 'gardening', 'reading', 'music' ],
// family: {
// firstName: 'Tobias',
// lastName: 'Robinson',
// relationship: 'brother'
// }
// }
console.log(newPerson)
// Output:
// {
// firstName: 'Lori',
// lastName: 'Robinson',
// email: 'lori.robinson@example.com',
// phoneNumber: '(476)-632-5186',
// hobbies: [ 'gardening', 'reading', 'music', 'skiing' ],
// family: {
// firstName: 'Tobias',
// lastName: 'Robinson',
// relationship: 'stepbrother'
// }
// }
Working with nested structures, pt2
This approach works well with structures that are more complex, but not too much. When we deal with more complex structures, it can quickly become a pain. Nobody wants to spread dozens of objects or arrays. In this case, we can use combination of spread
, JSON.parse()
and JSON.stringify()
.
With JSON.stringify()
, we can transform an object into a string. We can than transform it back to an object with JSON.parse()
. Finally, spread
will help us spread that parsed object into a new one. This will create a deep copy in which we can change any nested properties we want without accidentally changing the original.
// Create more complex object:
const person = {
firstName: 'Lori',
lastName: 'Robinson',
email: 'lori.robinson@example.com',
hobbies: ['gardening', 'reading', 'music'],
family: {
firstName: 'Tobias',
lastName: 'Robinson',
relationship: 'brother',
}
}
// This will work:
const newPerson = {
...JSON.parse(JSON.stringify(person)),
}
newPerson.hobbies.push('skiing')
delete newPerson.email
newPerson.family.relationship = 'stepbrother'
console.log(person)
// Output:
// {
// firstName: 'Lori',
// lastName: 'Robinson',
// email: 'lori.robinson@example.com',
// hobbies: [ 'gardening', 'reading', 'music' ],
// family: {
// firstName: 'Tobias',
// lastName: 'Robinson',
// relationship: 'brother'
// }
// }
console.log(newPerson)
// {
// firstName: 'Lori',
// lastName: 'Robinson',
// phoneNumber: '(476)-632-5186',
// hobbies: [ 'gardening', 'reading', 'music', 'skiing' ],
// family: {
// firstName: 'Tobias',
// lastName: 'Robinson',
// relationship: 'stepbrother'
// }
// }
Conclusion: Immutable arrays and objects in JavaScript, the native way
Working with arrays and objects in immutable way helps us keep our code predictable, track changes and avoid unexpected side effect. I hope that this tutorial made it easier for you to understand how you can work with arrays and objects while keeping them immutable.
Top comments (5)
Agreeing not to mutate data doesn't make the data immutable; someone else can still come along and mutate our data, or we might do so ourselves by simply missing a mutation somewhere.
This doesn't only affect us, but also the language: when no data is actually immutable, it's harder to do optimisations like copy-on-write that make many functional languages more performant.
Given that, yes, nothing makes an array immutable, I think the point of this article was to show what you can do under that constraint - short of inventing new collection types, which is it's own bag of worms, a commitment not to mutate is the next best thing.
We generally have control of what we do within the boundaries of our own modules, and I tend to prefer immutable operations mainly because it makes testing easier. As with any form of side effects, that requires discipline, which, yeah, that's not great, but under the circumstances, it's better than the alternative of making more of the kind of mess you get with JS standard types like Array.
For example, if my function is going to sort an array, I generally
slice()
before Isort
, just in case. Yeah, that wastes CPU time and memory - in most cases not much though, as strings and objects don't get aggressively copied when youslice
.No big deal. And it means I ship pure functions that are easier to test and reason about. Programmer hours in most cases cost more than CPU time - avoid premature optimizations and so on. π
Shift can also be achieved with the spread operator:
nice. another deep clone approach for me.
Note, all these shallow clones can deeply impact performance when done in larger loops/each/map.