DEV Community

Cover image for Immutable arrays and objects
Miroslav Jonas
Miroslav Jonas

Posted on • Originally published at ultimatecourses.com

Immutable arrays and objects

Cover photo by Dušan Smetana on Unsplash

Recently there has been an explosion of popularity of libraries like Redux and NGRX. One common requirement they have is an immutable state. The state of the application is a result of a list of actions sequentially applied to the initial state. Each state of the application is unchangeable. A new action uses the existing state to calculate a new one. This helps us to avoid accidental state changes via mutable operations. It also allows us to investigate which actions led to our current state.

Normally, we describe states through objects and arrays:

const state = {
  userName: 'jdoe',
  favouriteColours: ['blue', 'orange', 'green'],
  company: 'UltimateCourses',
  skills: ['javascript', 'react', 'vue', 'angular', 'svelte']
};

Even simple state changes, normally done with two-way binding (e.g. v-model in Vue or ngModel in Angular), could benefit from the immutable approach. We do this by making a copy of the component's input, mutating the copy and output the mutated copy to the caller. This largely reduces the potential for side effects.

Common state action is to add or remove items from an array or to add or remove fields from an object. However, the standard operations are mutating the original object. Let's see how we can apply them in an immutable way. Our goal is to create a new object, rather than changing the existing. For simplicity, we will be using rest and spread operators introduced in ES6, but all this is possible (albeit less elegantly) with ES5 functions as well.

Immutable array operations

Array has several mutable operations - push, pop, splice, shift, unshift, reverse and sort. Using them is usually causing side effects and bugs that are hard to track. That's why it's important to use an immutable way.

Push

Push is an operation that adds a new item on top of the array.

const fruits = ['orange', 'apple', 'lemon'];
fruits.push('banana'); // = ['orange', 'apple', 'lemon', 'banana']

The resulting array is a concatenation of the original array and the item. Let's try to accomplish that in an immutable way:

const fruits = ['orange', 'apple', 'lemon'];
const newFruits = [...fruits, 'banana']; // = ['orange', 'apple', 'lemon', 'banana']

The spread operator ... here is 'spreading' the items of the array as arguments.

Unshift

Unshift is an operation similar to push. However, instead of adding the item at the end we will prepend the item at the beginning of the array.

const fruits = ['orange', 'apple', 'lemon'];
fruits.unshift('banana'); // = ['banana', 'orange', 'apple', 'lemon']

Similarly, we will use a spread operation to achieve immutability, but with a slight modification:

const fruits = ['orange', 'apple', 'lemon'];
const newFruits = ['banana', ...fruits]; // = ['banana', 'orange', 'apple', 'lemon']

Pop

Pop is an operation that removes the last item from the end of the array and returns it.

const fruits = ['orange', 'apple', 'lemon', 'banana'];
const lastFruit = fruits.pop(); // = 'banana', fruits = ['orange', 'apple', 'lemon']

To remove the item in an immutable way we will use slice. Note that we are making a copy of the last item before this operation. If the copy is not needed we can skip the second line, of course.

const fruits = ['orange', 'apple', 'lemon', 'banana'];
const lastFruit = fruits[fruits.length - 1]; // = 'banana'
const newFruits = fruits.slice(0, fruits.length - 1); // = ['orange', 'apple', 'lemon']

Shift

The shift is an operation similar to pop, but instead of removing the item from the end we remove the item from the beginning of the array.

const fruits = ['orange', 'apple', 'lemon', 'banana'];
const firstFruit = fruits.shift(); // = 'orange', fruits = ['apple', 'lemon', 'banana']

Our immutable solution is equivalent to the immutable pop. We don't have to specify the end limit of slice operation if we want to take all items until the end.

const fruits = ['orange', 'apple', 'lemon', 'banana'];
const firstFruit = fruits[0]; // = 'orange'
const newFruits = fruits.slice(1); // = ['apple', 'lemon', 'banana']

Removal and inserting of items

To add or remove an item from an array, we usually use splice.

const fruits = ['orange', 'apple', 'lemon', 'banana'];
// remove two items from position 1, and replace it with 'strawberry'
fruits.splice(1, 2, 'strawberry'); // = ['orange', 'strawberry', 'banana']

Combined slice and spread gives us the same result, but in an immutable fashion:

const fruits = ['orange', 'apple', 'lemon', 'banana'];
const newFruits = [...fruits.slice(0, 1), 'strawberry', ...fruits.slice(3)]; // = ['orange', 'strawberry', 'banana']

Sort and reverse

Sort and reverse are operators that, respectively, sort and invert the array's items order.

const fruits = ['orange', 'apple', 'lemon', 'banana'];
fruits.sort(); // = ['apple', 'banana', 'lemon', 'orange'];
fruits.reverse(); // = ['orange', 'lemon', 'banana', 'apple'];

Both, sort and reverse, are mutable in nature. However, using spread, we can make a copy of the array so the mutation happens on the copy, instead of the original array.

const fruits = ['orange', 'apple', 'lemon', 'banana'];
const sorted = [...fruits].sort(); // = ['apple', 'banana', 'lemon', 'orange'];
const inverted = [...fruits].reverse(); // = ['banana', 'lemon', 'apple', 'orange'];
const sortedAndInverted = [...sorted].reverse(); // = ['orange', 'lemon', 'banana', 'apple'];

Thanks to the immutability, we can now separate sorting from inversion. As a result, we have all four variants (including the original array) available.

Immutable object operations

State objects tend to grow in applications. However, for certain functionality of the application, we don't need the full state. Usually, we change a small portion of the object and then merge it back. Let's learn how to split and change the object, without affecting the original.

Modify and/or add property

Let's say we want to change the selected fruit and set the new quantity. The standard way to do it is by mutating the object.

const state = {
  selected: 'apple',
  quantity: 13,
  fruits: ['orange', 'apple', 'lemon', 'banana']
};
state.selected = 'orange';
state.quantity = 5;
state.origin = 'imported from Spain';
/*
state = {
  selected: 'orange',
  quantity: 5,
  fruits: ['orange', 'apple', 'lemon', 'banana'],
  origin: 'imported from Spain'
}
*/

Again, we can leverage the spread operator to create a copy of the object with fields changed. The spread here is, similar to array, spreading the key-value pairs of the original object onto a new one. With the next two lines, we are overriding the values from the original object. The last line is creating a new field called 'origin'.

const state = {
  selected: 'apple',
  quantity: 13,
  fruits: ['orange', 'apple', 'lemon', 'banana']
};
const newState = {
  ...state,
  selected: 'orange',
  quantity: 5,
  origin: 'imported from Spain'
};
/*
newState = {
  fruits: ['orange', 'apple', 'lemon', 'banana'],
  selected: 'orange',
  quantity: 5,
  origin: 'imported from Spain'
}
*/

Remove a property

To remove an object's property in a mutable way, we will simply call delete:

const state = {
  selected: 'apple',
  quantity: 13,
  fruits: ['orange', 'apple', 'lemon', 'banana']
};
delete state.quantity;
/*
state = {
  selected: 'apple',
  fruits: ['orange', 'apple', 'lemon', 'banana']
}
*/

Removing a property in an immutable way requires a little trick provided by the spread's counterpart rest. The rest operator is written in the same way as spread - with .... However, the meaning, in this case, is not to spread all the fields, but rather the remaining ones.

const state = {
  selected: 'apple',
  quantity: 13,
  fruits: ['orange', 'apple', 'lemon', 'banana']
};
const { quantity, ...newState } = state;
/*
quantity = 13
newState = {
  selected: 'apple',
  fruits: ['orange', 'apple', 'lemon', 'banana']
}
*/

This technique is called destructuring assignment as we are unpacking the original state object. We assign the quantity key-value pair to constant quantity and assign the rest of the object to newState.

Complex structures

Complex structures have nested arrays or objects. In the following example, state has nested array gang.

const state = {
  selected: 4,
  gang: [
    'Mike',
    'Dustin',
    'Lucas',
    'Will',
    'Jane'
  ]
};
const newState = { ...state };
newState.selected = 11;
newState.gang.push('Max');
newState.gang.push('Suzie');
/*
state = {
  selected: 4,
  gang: [
    'Mike',
    'Dustin',
    'Lucas',
    'Will',
    'Jane'
    'Max',
    'Suzie'
  ]
}
newState = {
  selected: 11,
  gang: [
    'Mike',
    'Dustin',
    'Lucas',
    'Will',
    'Jane'
    'Max',
    'Suzie'
  ]
}
state.gang === newState.gang
*/

Not what we expected, right? Performing spread operation on the complex structures makes just a shallow (first level) copy of the structure. Here it only copied the reference to the gang array, not the actual array. Adding new elements to the array influenced both state and newState. To solve this we need to spread the array separately.

const newState = {
  ...state,
  gang: [...state.gang]
};

However, gang could also be a complex structure (e.g. array of objects). If we change one of the objects underneath, it will change in both arrays.

const state = {
  selected: 4,
  gang: [
    { id: 1, name: 'Mike' },
    { id: 2, name: 'Dustin' },
    { id: 3, name: 'Lucas' },
    { id: 4, name: 'Will' },
    { id: 11, name: 'Jane' }
  ]
}
const newState = {
  selected: 11,
  gang: [...state.gang]
}
newState.gang[4].name = 'Eleven';
/*
state = {
  selected: 4,
  gang: [
    { id: 1, name: 'Mike' },
    { id: 2, name: 'Dustin' },
    { id: 3, name: 'Lucas' },
    { id: 4, name: 'Will' },
    { id: 11, name: 'Eleven' }
  ]
}
newState = {
  selected: 11,
  gang: [
    { id: 1, name: 'Mike' },
    { id: 2, name: 'Dustin' },
    { id: 3, name: 'Lucas' },
    { id: 4, name: 'Will' },
    { id: 11, name: 'Eleven' }
  ]
}
*/

One solution would be to spread also every gang member object, but this can go on forever. Also, we might not know how many levels there are. Not to worry, as there is a trick that handles all those cases.

Calling JSON.parse(JSON.stringify(obj)) makes a deep clone of an object. It converts an object to a string representation and then parses it back to a new object. All references from the original object remain intact.

In most cases, of course, spread on the first level is enough. But we need to be aware of this peculiar behavior to circumvent the potential problems.

Conclusion

We learned how we can replace mutable operations with their immutable counterparts. Switching to the immutable state helps us to more easily reason about our state of the application and to easily track changes. It also helps us to avoid an unplanned side effect.

Please have in mind that immutable operations are recreating the array or object every time. If you are dealing with large objects or collections, this might not be the ideal way to handle your data. There are some libraries that are specialized in fast immutable operations (e.g. Immutable JS or Immer), so if you hit the performance roadblock with immutable operations, be sure to check them out.


Used materials:

Top comments (0)