Immutability in JavaScript
New developers often hear the term "Immutability" when first starting to learn React, but this is not a feature of React. Rather, this is the way the JavaScript language works and it works in this manner due to the way primitives and objects are stored in memory.
The canonical definition for immutability typically is, "an immutable object is an object that cannot be changed." This is confusing because we know that an object can in fact be changed. In order to make this a bit more clear we first need to take a look at how primitive types behave in JavaScript then we will take a look at objects.
Primitive Types are Immutable in JavaScript
All data types in JavaScript are immutable except for objects.
let str1 = 'a'
/**
* str2 has is assigned the value of str1
*/
let str2 = str1
console.log(str2) // 'a'
/**
* str2 value is re-assigned
*/
str2 = 'b'
console.log(str2) // 'b'
/**
* this is the important part, str1 is still 'a'
* it is immutable and it's value does NOT change
*/
console.log(str1) // 'a'
Notice how str1
is set to 'a'
and it stays that way. This is because str1 is immutable. We can copy the value of str1
and assign that value to str2
. Now when we change str2
you notice that str1
still keeps the value 'a'
. The reason this is happening is because when str1
is created it gets it's own slot in memory, and when str2
is created, it also gets it's own slot in memory.
Objects are not Immutable in JavaScript
Objects in JavaScript behave much differently than primitives. This is because when str1 = 'a'
is instantiated, str1
value is 'a'
. However, when an object gets created, its value is a memory address, and that memory address stores the value.
/**
* the value of obj is NOT { num: 1 }
* it is a memory address i.e. 0x01
* address 0x01 stores { num: 1 }
*/
const obj = {
num: 1
}
/**
* obj2 value is the memory address of obj1
* therefore modifications to obj2 are modifications to the
* value stored at 0x01 memory address.
*/
const obj2 = obj
obj2.num = 2
console.log(obj2) // { num: 2 }
/**
* since obj1 and obj2 both point to 0x01
* they have the same value
*/
console.log(obj) // { num: 2 }
Since arrays are also objects so we can observe the same behavior.
/**
* variable a points to some memory address i.e. 0x02
*/
const a = ['a']
/**
* variable b now has the value 0x02
*/
const b = a
/**
* modifications to b also affect a
*/
b.push('b')
console.log(b) // ['a', 'b']
console.log(a) // ['a', 'b']
Update Objects in an Immutable Way
In order to update an object in an immutable way, we can utilize the spread operator. But first we need to understand something important. Below there are some pairs of values that appear to be equal but they are not.
[] == [] // false
/**
* this is the same as
*/
new Array() === new Array() // false
{} === {} // false
new Object() === new Object() // false
[1, 2] === [1, 2] // false
{ id: 1 } === { id: 1 } // false
/**
* compared to primitives
*/
'hi' === 'hi' // true
1 === 1 // true
Each time there is a pair of curly brackets {}
also known as an object literal notation or a set of square brackets []
, referred to as array literal notation, a new place in memory is allocated.
const arr1 = [1, 2]
const arr2 = [1, 2]
/**
* arr1 value is 0x01
* arr2 value is 0x02
*
* 0x01 stores [1, 2]
* 0x02 stores [1, 2]
*
* therefore false is evaluated
*/
arr1 === arr2 // false
Below, arr2
is not creating a new array, there is no array literal notation []
or array contructor new Array()
. It is simply pointing at the value of arr1
.
const arr1 = [1, 2] // lives at 0x01
const arr2 = arr1
/**
* arr1 value is 0x01
* arr2 value is arr1 (0x01)
*
* therefore true is evaluated
*/
arr1 === arr2 // true
Now that we know how objects and primitives work under the hood a bit, now we can look at how to update an object in an immutable way.
/**
* object literal notation {} creates a new memory address
*/
const obj1 = { name: 'david' }
/**
* obj2 uses object literal notation and creates a new memory address
* the values of obj1 are copied into obj2 and the name property is overridden
*/
const obj2 = { ...obj1, name: 'john'}
console.log(obj1) // { name: 'david'}
console.log(obj2) // { name: 'john' }
Arrays are also objects and the same behavior can be observed.
/**
* array literal notation [] creates a new place in memory
*/
const arr1 = [1]
/**
* arr2 creates another new place in memory
* and copies the values of arr1
*/
const arr2 = [...arr1]
arr2.push(2)
console.log(arr2) // [1, 2]
/**
* still the same
*/
console.log(arr1) // [1]
Update Nested Properties of Arrays and Objects
An object can be updated in an immutable way using an object literal {}
or with an array literal []
and the spread operator. This might seem confusing though when dealing with a nested object or a multi dimensional array, but the same principles still apply.
/**
* here is a nested object moreInfo
*/
const obj1 = {
name: 'david',
moreInfo: {
age: 29
}
}
/**
* this is a naive approach and will not give us
* the desired behavior
*/
const obj2 = { ...obj1 }
obj2.moreInfo.age = 30
/**
* obj2 age has been successfully updated
*/
console.log(obj2) // { name: 'david', moreInfo: { age: 30 } }
/**
* we accidentally modified obj1
*/
console.log(obj1) // { name: 'david', moreInfo: { age: 30 } }
The correct way to solve this is to use the spread operator for the nested objects.
const obj1 = {
name: 'david',
moreInfo: {
age: 29
}
}
/**
* moreInfo is copied into a new object
* and age is overidden
*/
const obj2 = {
...obj1,
moreInfo: {
...obj1.moreInfo,
age: 30
}
}
Take this code for example, at first glance it seems moreInfo
is "inside" of obj1
, but this is not the case at all.
/**
* it might appear there is a nested object here
* but there are two seperate objects
*/
const obj1 = {
name: 'david',
moreInfo: {
age: 29
}
}
/**
* this could be re-written to
*/
const info = {
age: 29
}
const obj1 = {
name: 'david',
moreInfo: info
}
It's important to remember that moreInfo
is not inside of obj1
. For example, obj2
could "point" at info
too.
const info = {
age: 29
}
const obj1 = {
name: 'david',
moreInfo: info
}
const obj2 = {
name: 'john',
moreInfo: info
}
obj2.moreInfo.age
, it would affect obj1.moreInfo.age
and info.age
. This is because obj2.moreInfo
, obj1.moreInfo
, and info
are all the same object. This is not so easy to see when you think of objects as "nested", rather they are seperate objects "pointing" at each other with properties.The same goes for arrays, although this wouldn't normally be used, it is an example of the same behavior we just observed with objects.
0
is stored. At index 1, the value is a memory address because index 1 is an array./**
* index 0 is a number
* index 1 is an array
*/
const arr1 = [0, [1, 2]]
const arr2 = [...arr1]
// get the '1' in the nested array
arr2[1][0] = 3
console.log(arr2) // [0, [3, 2] ]
console.log(arr1) // [0, [3, 2] ]
As we said earlier, there aren't really nested objects in JavaScript and there are not nested arrays in JavaScript either. That is why the code could be re-written to this.
const arr1 = [1, 2]
const arr2 = [0, arr1]
Then to immutably update arr2
const arr1 = [1, 2]
const arr2 = [0, arr1]
const arr3 = [arr2[0], [...arr2[1]]]
arr3[1][0] = 3
console.log(arr3) // [0, [ 3, 2] ]
console.log(arr2) // [0, [1, 2]]
Hard to Catch Bugs
Understanding how immutabilty works is very important as it can lead to some hard to catch bugs.
const arr = [6, 2, 3]
function sortArray(someArr) {
return someArr.sort((a, b) => a - b)
}
sortArray(arr) // [2, 3, 6]
// oops, we mutated arr by accident
console.log(arr) // [2, 3, 6]
Since sort()
modifies the array in place and returns the reference to the same array sorted, we need to modify the sortArray
function.
const arr = [6, 2, 3]
function sortArray(someArr) {
return [...someArr].sort((a, b) => a - b)
}
sortArray(arr) // [2, 3, 6]
// arr is not mutated
console.log(arr) // [6, 2, 3]
map()
. If Array.prototype.map()
were used there would be no need to use [...x]
syntax.const arr = [1, 2, 3]
function doubleElements(someArr) {
return someArr.map(x => x * 2)
}
doubleElements(arr) // [2, 4, 6]
// arr is not mutated
console.log(arr) // [1, 2, 3]
Conclusion
Primitives such as strings store a value, but the value of an object is a memory address and that memory address is where the value lives. This concept is often referred to as value vs reference. Value vs reference is an important topic to understand in JavaScript, and immutability refers to how we can update a value without affecting the other. Objects and arrays point to memory addresses and primitives simply store a value.
Most of the time developers will hear of value vs reference when learning JavaScript and they first hear of immutability if they try to learn React. Immutability and value/reference are interwoven, both concepts relate to one another. It's important to understand that immutability is not a JavaScript feature, rather it's simply a side effect of how the language works. Since objects point to a location in memory, if we want to update one object while leaving the other untouched, we need to update the object in an immutable way and this is simply referred to as immutability.