DEV Community

Cover image for Immutable Arrays and Objects in JavaScript, the Native Way
Alex Devero
Alex Devero

Posted on • Originally published at blog.alexdevero.com

Immutable Arrays and Objects in JavaScript, the Native Way

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' ]
Enter fullscreen mode Exit fullscreen mode

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' ]
Enter fullscreen mode Exit fullscreen mode

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' ]
Enter fullscreen mode Exit fullscreen mode

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' ]
Enter fullscreen mode Exit fullscreen mode

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' ]
Enter fullscreen mode Exit fullscreen mode

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' ]
Enter fullscreen mode Exit fullscreen mode

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' ]
Enter fullscreen mode Exit fullscreen mode

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'
// }
Enter fullscreen mode Exit fullscreen mode

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'
// }
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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' ]
// }
Enter fullscreen mode Exit fullscreen mode

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'
//   }
// }
Enter fullscreen mode Exit fullscreen mode

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'
//   }
// }
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
darkwiiplayer profile image
π’ŽWii πŸ³οΈβ€βš§οΈ

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.

Collapse
 
mindplay profile image
Rasmus Schultz

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 I sort, 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 you slice.

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. πŸ™‚

Collapse
 
costinmanda profile image
Costin Manda

Shift can also be achieved with the spread operator:

const arr=[1,2,3];
const [x,...y]=arr;
// now x = 1 and y = [2,3]
Enter fullscreen mode Exit fullscreen mode
Collapse
 
tanth1993 profile image
tanth1993

nice. another deep clone approach for me.

Collapse
 
tracker1 profile image
Michael J. Ryan

Note, all these shallow clones can deeply impact performance when done in larger loops/each/map.