Immutable.js, the 80/20 Rule for React and Redux
I've scoured the internet for some basic information on Immutable.js to help with the performance on some of my React applications. But most only cover some of the basics or examples that don't use data structures that are common in most API.
Luckily for most coming from a functional programming background, (in JS, those familiar with lodash or underscore) the conceptual mental models and DSL's are relatively the same in Immutable.js.
First we have to understand the two main data structures: Map and List
Map
const map = Immutable.Map({ id: 1, name: 'John' });
map.get('name') == 'John'; // true
map.set('name', 'Mike');
map.get('name') == 'Mike'; // true
List
const list = Immutable.List([5, 20, 101];
list.get(1) == 20; // true
list.set(0, 9);
list.get(0) == 9; // true
list.set(4, 30);
list.toJS(); // [9, 20, 101, undefined, 30]
Converting to Immutable.js, fromJS magically detects the proper data structure to convert to. In this example, Immutable.js knows to create a List of Map from an Array of Objects.
const data = [ { id: 1, name: "John" }, { id: 2, name: "Adam" } ];
const magic = Immutable.fromJS(data);
Immutable.List.isList(magic); // true
Immutable.Map.isMap(magic.get(0)); // true
Of course, this can done manually as shown below
const result = [ { id: 1, name: "John" }, { id: 2, name: "Adam" } ];
const magic = Immutable.fromJS(result);
let list = Immutable.List();
result.map((item, index) => { list = list.set(index, Immutable.Map(item))});
Immutable.is(list, magic); // true,
Okay, with these basic data structure building blocks, lets build a basic redux store using Immutable.js
Let's take a look at this Person reducer using List and Map
const initialState = Immutable.List([
Immutable.Map({ id: 1, name: "John Snow", title: "King of the North" }),
Immutable.Map({ id: 2, name: "Adam", title: "Butcher" })
]);
function personReducer(state = initialState, action) {
switch (action.type) {
case FETCH_ALL_PEOPLE: // O(n)
action.payload.map((item, index) => {
state = state.set(index, Immutable.Map(item));
});
// OR state = Immutable.fromJS(action.payload);
return state;
case FETCH_ALL_KINGS: // O(n^2)
action.payload.map((king, index) => {
const index = state.findIndex((item) => {
return item.get('id') === king.id;
});
if ( index >= 0 ) {
state = state.set(index, Immutable.Map(king));
} else {
state.push(Immutable.Map(king));
}
});
return state;
case CREATE_PERSON: // O(1)
return state.push(Immutable.Map(action.payload));
case GET_PERSON: // O(n)
const index = state.findIndex((item) => {
return item.get('id') === action.payload.id;
});
if ( index >= 0 ) {
return state.update(index, (person) => {
return person.set('name', action.payload.name);
});
} else {
state.push(Immutable.Map(action.payload));
}
return state;
case DELETE_PERSON: // O(n)
const index = state.findIndex((item) => {
return item.get('id') === action.payload.id;
});
return state.delete(index);
case UPDATE_PERSON: // O(n)
const index = state.findIndex((item) => {
return item.get('id') === action.payload.id;
});
if ( index >= 0 ) {
state = state.set(index, Immutable.Map(action.payload));
}
return state;
default:
return state;
}
}
Things are okay here except when we FETCH_ALL_KINGS
and get O(n^2) by trying to combine an Array with a List. We also have the following sprinkled in the code to check for duplicates which makes those cases a minimum of O(n) complexity
const index = state.findIndex((item) => {
return item.get('id') === action.payload.id;
});
Set
Set duplicate checks are done very efficiently. Lets rewrite the above code using Set instead of List
const initialState = Immutable.Set([
Immutable.Map({ id: 1, name: "John Snow", title: "King of the North" }),
Immutable.Map({ id: 2, name: "Adam", title: "Butcher" })
]);
function personReducer(state = initialState, action) {
switch (action.type) {
case FETCH_ALL_PEOPLE: // O(nlogn)
case FETCH_ALL_KINGS: // O(nlogn)
action.payload.map((item, index) => {
state = state.add(Immutable.Map(item));
});
return state;
case CREATE_PERSON: // O(logn)
case GET_PERSON: // O(logn)
return state.add(Immutable.Map(action.payload));
case DELETE_PERSON: // O(n)
return state.delete(Immutable.Map(action.payload));
case UPDATE_PERSON: // O(n)
// This is a combo of DELETE_PERSON then CREATE_PERSON
const found = state.find((person) => {
return action.payload.id == person.get('id');
});
if (found) {
state = state.delete(Immutable.Map(found));
state = state.add(Immutable.Map(action.payload));
}
return state;
default:
return state;
}
}
The code state.add(Immutable.Map(action.payload));
checks for duplicates and if it does't exist, it adds it to the Set. Similarly, state.delete(Immutable.Map(action.payload))
is able to find the same data objects and delete it properly.
With this, you'll notice that the worse efficiency is at O(nlogn) which is a great trade off vs O(n^2). The code is also more clean and readable. A drawback of Set is that it isn't ordered like List so if order isn't important, the tradeoffs are worth it.
Record
If you noticed with Map person.get('name')
isn't nearly as elegant as person.name
in vanilla javascript on top of also losing a lot of the object syntactic sugar ES6+ provides. Luckily Immutable.js provides the data structure Record, which is essentially Map but can be treated like a javascript object
const Person = Immutable.Record({ id: "", name: "" });
const person = new Person({ id: 1, name: "John Snow" });
person.name // "John Snow"
const { name } = person; // name = "John Snow"
We just covered the basics of Map, List, Set and Record. These data structures is just the tip of the iceberg but should be a great starting point for Immutable.js