Strategies for immutable JavaScript
Immutable data is a concept that I had to deal with while writing my first reducer for ngrx/store. The idea behind immutable data is that it cannot be changed after creation. This simple shift in state management can make applications more simple to build and easier to debug. With a single source of truth you have a contract in hand that will free you of a whole class of problems that plague mutable applications. I’ll share what I’ve learned so far. Keep in mind that these examples stem from my experiences using ngrx/store but are in no way directly tied it.
Let’s start with some state: This state will consist of an object with an array of ids and an entities object that will store one key/value for each id in the ids array.
let states = [];
const initialState = {
ids: [],
entities: {}
}
states = [...states, initialState]
Adding some data to our empty state:
// add john
const john = {id: 1, name: 'John Lennon'};
const solo = {
ids: [...initialState.ids, john.id],
entities: Object.assign({}, initialState.entities, {[john.id]: john})
}
states = [...states, solo];
There’s a lot going on here. We’ll look at one thing at a time. john
is the new object that we want to add to the state.
const john = {id: 1, name: 'John Lennon'};
From the object john
we’ll extract the id property to add to the ids
array and copy the id and name properties into the entities
object.
const solo = {
ids: [] //add new ids,
entities: {} //add new entities
}
Adding new ids: The spread operator is used to expand any existing ids in place, followed by the new object’s id. The solo.ids
array will have no references to the initialState.ids
.
ids: [...initialState.ids, john.id]
Adding new entities: The Object.assign
method is used to create a new entities object. The first argument is a newly created object followed initialState.entities
. The key/values from initialState.entities
will be shallow copied into the new empty object. Lastly, the object {[john.id]: john}
will be merged with the newly created object.
entities: Object.assign({}, initialState.entities, {[john.id]: john})
In the end the state solo will look like this.
{
ids: [1],
entities: {
1: {id: 1, name: 'John Lennon'}
}
}
Adding more data: The others
array contains new objects to be added to the state. We’ll need to extract the ids from array and reduce the objects in it to one single object.
// add paul, george and ringo
const others = [
{id: 2, name: 'Paul McCartney'},
{id: 3, name: 'George Harrison'},
{id: 4, name: 'Ringo Starr'}
];
let newIds = others.map(x => x.id);
let newEntities = others.reduce((acc, member) => {
return Object.assign(acc, {[member.id]: member});
},{})
const fabFour = {
ids: [...solo.ids, ...newIds],
entities: Object.assign({}, solo.entities, newEntities)
};
states = [...states, fabFour];
First the ids: newIds = others.map(x => x.id)
creates a new array named newIds
that has no reference to the others
array. Next combine newIds
with solo.ids
by using the spread operator like this. ids: […solo.ids, …others.map(x => x.id)]
.
The entities will take a little more work: reduce
and Object.assign
are used together to shallow copy all the members of the others array into a single new object called newEntities
. As before, there are no references to the old data. The new data has been copied. No mutations have occurred.
let newEntities = others.reduce((acc, member) => {
return Object.assign(acc, {[member.id]: member});
},{})
Assembling the state: It’s as simple as copying the existing data and the new data into a new object. The spread operator is used to expand the existing solo.ids
and the newIds
, which are combined together into the fabFour.ids
array. Object.assign
is used to merge the existing key/value pairs of solo.entities
and newEntities
into the new empty object {}
.
const fabFour = {
ids: [...solo.ids, ...newIds],
entities: Object.assign({}, solo.entities, newEntities)
};
Adding more data: With some data in the state, let’s take another look at adding a new object. This should look very familiar. This is the same process used to add john
to the earlier state.
//add billy
const billy = {id: 5, name: 'Billy Preston'};
const fabFourV1 = {
ids: [...fabFour.ids, billy.id],
entities: Object.assign({}, fabFour.entities, {[billy.id]: billy})
}
Removing and replacing data: Let’s remove billy
and replace it with eric
. The strategies are the same as we’ve used so far. First, filter the ids to newIds
. newIds
will no longer contain billy.id
. Next, reduce newIds
down to the newEntities
object looking up each item from the newIds
array in the fabFourV1.entities
object. In the end newEntities
will no longer contain the billy
object. Finally, merge eric.id
with the filtered newIds
and merge eric
with the filtered newEntities
.
//remove billy and replace him with eric
const eric = {id: 6, name: 'Eric Clapton'};
newIds = fabFourV1.ids.filter(x => x != billy.id)
newEntities = newIds.map(id => fabFourV1.entities[id]).reduce((acc, member) => {
return Object.assign(acc, {[member.id]: member});
},{});
fabFourV2 = {
ids: [...newIds, eric.id],
entities: Object.assign({}, newEntities, {[eric.id]: eric})
}
There have been 5 states created in this exercise; initialState
, solo
, fabFour
, fabFourV1
, fabFourV2
. Each state was created with immutable methods. There are no references shared between them.
for (var i = 0, len = states.length; i < len; i++) {
console.log(states[i].ids.map(id => states[i].entities[id].name));
}
This output shows that each state is a separate object.
[]
["John Lennon"]
["John Lennon", "Paul McCartney", "George Harrison", "Ringo Starr"]
["John Lennon", "Paul McCartney", "George Harrison", "Ringo Starr", "Billy Preston"]
["John Lennon", "Paul McCartney", "George Harrison", "Ringo Starr", "Eric Clapton"]
No states were mutated during the creation of this article. The full source can be found here.