What is immer?
Immer is a package that allows you to work with immutable state in a more convenient way. It uses a copy-on-write mechanism to ensure that the original state is not mutated. Instead, Immer produces a new updated state based on the changes made within a 'produce' function. This approach simplifies the process of updating immutable data structures, especially in the context of modern JavaScript frameworks and libraries such as React and Redux.
What are immer's main functionalities?
Creating the next immutable state by modifying the current state
This feature allows you to pass a base state and a producer function to the 'produce' function. Within the producer function, you can mutate the draft state as if it were mutable. Immer takes care of applying the changes to produce the next immutable state.
import produce from 'immer';
const baseState = [
{todo: 'Learn typescript', done: true},
{todo: 'Try immer', done: false}
];
const nextState = produce(baseState, draftState => {
draftState.push({todo: 'Tweet about it'});
draftState[1].done = true;
});
Working with nested structures
Immer can handle deeply nested structures with ease. You can update deeply nested properties without the need to manually copy every level of the structure.
import produce from 'immer';
const baseState = {
user: {
name: 'Michele',
age: 33,
todos: [
{title: 'Tweet about it', done: false}
]
}
};
const nextState = produce(baseState, draftState => {
draftState.user.age = 34;
draftState.user.todos[0].done = true;
});
Currying
Immer supports currying, which means you can predefine a producer function and then apply it to different states. This is useful for creating reusable state transformers.
import produce from 'immer';
const baseState = {counter: 0};
const increment = produce(draft => {
draft.counter++;
});
const nextState = increment(baseState);
Other packages similar to immer
immutable
Immutable.js is a library by Facebook that provides persistent immutable data structures. Unlike Immer, which allows you to write mutable code that gets converted to immutable updates, Immutable.js requires you to use specific methods to update data structures. It offers a wide range of data structures like List, Map, Set, etc.
mori
Mori is a library that brings Clojure's persistent data structures to JavaScript. It is similar to Immutable.js in that it provides a variety of immutable data structures and functional programming utilities. Mori's API is quite different from JavaScript's native arrays and objects, which can have a steeper learning curve compared to Immer.
seamless-immutable
Seamless-immutable is a library that provides immutability for your data structures without drastically changing the syntax of standard JavaScript objects and arrays. It is less powerful than Immer in terms of handling complex updates and nested structures but offers a simpler and more familiar API for those who prefer to work with plain JavaScript objects.
Immer
Create the next immutable state tree by simply modifying the current tree
Immer (German for: always) is a tiny package that allows you to work with immutable state in a more convenient way.
It is based on the copy-on-write mechanism.
The basic idea is that you will apply all your changes to a temporarily draftState, which is a proxy of the currentState.
Once all your mutations are completed, immer will produce the nextState based on the mutations to the draft state.
This means that you can interact with your data by simply modifying it, while keeping all the benefits of immutable data.
Using immer is like having a personal assistant; he takes a letter (the current state), and gives you a copy (draft) to jot changes onto. Once you are done, the assistant will take your draft and produce the real immutable, final letter for you (the next state).
A mindful reader might notice that this is quite similar to withMutations
of ImmutableJS. It is indeed, but generalized and applicable to plain, native JavaScript data structures (arrays and objects) without further needing any library.
API
The immer package exposes a single function:
immer(currentState, fn: (draftState) => void): nextState
Example
const baseState = [
{
todo: "Learn typescript",
done: true
},
{
todo: "Try immer",
done: false
}
]
const nextState = immer(baseState, draftState => {
draftState.push({ todo: "Tweet about it" })
draftState[1].done = true
})
The interesting thing about immer
is that, the baseState
will be untouched, but the nextState
will reflect all changes made to draftState
.
expect(baseState.length).toBe(2)
expect(nextState.length).toBe(3)
expect(baseState[1].done).toBe(false)
expect(nextState[1].done).toBe(true)
expect(nextState[0]).toBe(baseState[0])
expect(nextState[1]).not.toBe(baseState[1])
Benefits
- Use the language© to construct create your next state
- Strongly typed, no string based paths etc
- Deep updates are trivial
- Small, dependency free library with minimal api surface
- No accidental mutations of current state, but intentional mutations of a draft state
Auto freezing
Immer automatically freezes any state trees that are modified using `immer.
This protects against accidental modifications of the state tree outside of an immer function.
This comes with a performance impact, so it is recommended to disable this option in production.
It is by default enabled.
Use setAutoFreeze(true / false)
to turn this feature on or off.
Reducer Example
A lot of words; here is a simple example of the difference that this approach could make in practice.
The todo reducers from the official Redux todos-with-undo example
Note, this is just a sample application of the immer
package. Immer is not just designed to simplify Redux reducers. It can be used in any context where you have an immutable data tree that you want to clone and modify (with structural sharing)
const todo = (state, action) => {
switch (action.type) {
case 'ADD_TODO':
return {
id: action.id,
text: action.text,
completed: false
}
case 'TOGGLE_TODO':
if (state.id !== action.id) {
return state
}
return {
...state,
completed: !state.completed
}
default:
return state
}
}
const todos = (state = [], action) => {
switch (action.type) {
case 'ADD_TODO':
return [
...state,
todo(undefined, action)
]
case 'TOGGLE_TODO':
return state.map(t =>
todo(t, action)
)
default:
return state
}
}
After using immer, that simply becomes:
import immer from 'immer'
const todos = (state = [], action) =>
immer(state, draftState => {
switch (action.type) {
case 'ADD_TODO':
draftState.push({
id: action.id,
text: action.text,
completed: false
})
return
case 'TOGGLE_TODO':
const todo = draftState.find(todo => todo.id === action.id)
todo.completed = !todo.completed
return
}
})
Creating middleware or a reducer wrapper that applies immer
automatically is left as exercise for the reader :-).
Performance
Here is a simple benchmark on the performance of immer
.
This test takes 100.000 todo items, and updates 10.000 of them.
These tests were executed on Node 8.4.0
performance
✓ just mutate (1ms) // No immutability at all
✓ deepclone, then mutate (647ms) // Clone entire tree, then mutate (no structural sharing!)
✓ handcrafted reducer (17ms) // Implement it as typical Redux reducer, with slices and spread operator
✓ immutableJS (81ms) // Use immutableJS and leverage `withMutations` for best performance
✓ immer - with autofreeze (309ms) // Immer, with auto freeze enabled
✓ immer - without autofreeze (148ms) // Immer, but without auto freeze enabled
Limitations
- This package requires Proxies, so Safari > 9, no Internet Explorer, no React Native on Android. This can potentially done, so feel free to upvote on #8 if you need this :)
- Currently, only tree shaped states are supported. Cycles could potentially be supported as well (PR's welcome)
- Currently, only supports plain objects and arrays. Non-plain data structures (like
Map
, Set
) not (yet). (PR's welcome)
Pitfalls:
- Make sure to modify the draft state you get passed in in the callback function, not the original current state that was passed as the first argument to
immer
! - Since immer uses proxies, reading huge amounts of data from state comes with an overhead. If this ever becomes an issue (measure before you optimize!), do the current state analysis before entering the
immer
block or read from the currentState
rather than the draftState
Credits
Special thanks goes to @Mendix, which supports it's employees to experiment completely freely two full days a month, which formed the kick-start for this project.