Introducing Immer: Immutability the easy way
And still; most of them don’t solve the root problem: lack of language support. For example, where
So what if we stopped fighting the language and embraced it instead? Without giving up on the elegance provided by persistent data structures. That is exactly what
Tip: if you don’t like reading, you can also watch the egghead tutorial for immer
Immer works by writing producers, and the simplest producer possible looks like this:
The produce function takes two arguments. The currentState and a producer function. The current state determines our starting point, and the producer expresses what needs to happen to it. The producer function receives one argument, the draft, which is a proxy to the current state you passed in. Any modification you make to the draft will be recorded and used to produce nextState. The currentState will be untouched during this process.
Because immer uses structural sharing, and our example producer above didn’t modify anything, the next state above is simply the state we started with.
Let’s take look at what happens when we start modifying the draft in our producer. Note that the producer function doesn’t return anything, the only thing that matters are the changes we make.
Here we actually see produce in action. We created a new state tree, which contains one extra todo item. Also, the status of the second todo was changed. These where the changes we applied to the draft, and they are nicely reflected in the resulting next state.
But there is more. The last statements in the listing show nicely that the parts of the state that were modified in the draft have resulted in new objects. However, unchanged parts are structurally shared with the previous state. The first todo in this case.
A reducer with a producer
Now we learned the basics of producing a new state. Let’s leverage this in an exemplary Redux reducer. The next gist is based on the official shopping cart example, and loads a bunch of (possibly) new products in the state. The products are received as an array, transformed using reduce, and then stored in a map with their id as key.
The boilerplaty part here is:
- We have to construct a new state object, in which the base state is preserved and the new products map is mixed in. It is not too bad in this simple case, but this process has to be repeated for every action, and on every level in which we want modify something.
- We have to make sure to return the existing state if the reducer doesn’t do anything
With Immer, we only need to reason about the changes we want to make relatively to the current state. Without needing to take the effort to actually produce the next state. So, when we use produce in the reducer, our code simply becomes:
Notice how much easier it is to grasp what
RECEIVE_PRODUCTS is actually doing? The noise has largely been removed. Also note that we don’t handle the default case. Not changing the draft simply equals returning the base state. Both the original reducer and the new one behave exactly the same.
No strings attached
The advantages go even further. To reduce boilerplate, ImmutableJS and many others allow you to express deep updates (and many other operations) with dedicated methods. These paths however are raw strings and cannot be verified by type-checkers. They are pretty error prone. In the following listing for example the type of
list cannot be inferred in the ImmutableJS case. Other libraries take this even a step further and even fiddle their own DSLs into these path queries, enabling more complex commands like splices. At the cost of introducing a mini-language in the language.
Another cool(😒) feature of Immer is that it will automatically freeze any data structure you created using
produce. (In development mode). So that you get truly immutable data. Where freezing the entire state would be pretty expensive, the fact that Immer can just freeze the changed parts makes it pretty efficient. And, if all your state is produced by
produce functions, the effective result will be that your entire state is always frozen. Which means you will get an exception when you try to modify the state in any way.
Ok. One last feature: So far we have always called
produce with two arguments, the
baseState and a
producer function. However, in some cases, it can be convenient to use partial application. It is possible to call
produce with just the producer function. This will create a new function that will execute the producer when it’s passed in a state. This new function also accepts an arbitrary amount of additional arguments and passes them on to the producer.
Don’t worry if you couldn’t parse the last sentences. What it boils down to is that you can further reduce the boilerplate of reducers by leveraging currying:
Ok, that is basically all there is to Immer. Feel free to start using it right away. But, you might be wondering at this point: How does this even work? Well, then, read on…
How does Immer work?
Well, two words; 1) Copy-on-write. 2). Proxies. Let me draw a picture.
The green tree is the original state tree. You will note that some circles in the green tree have a blue border around them. These are called proxies. Initially, when the producer starts, there is only one such proxy. It is the
draft object that get’s passed into your function. Whenever you read any non-primitive value from that first proxy, it will in turn create a Proxy for that value. So that means that you end up with a proxy tree, that kind of overlays (or shadows) the original base tree. Yet, only the parts you have visited in the producer so far.
Now, as soon as you try to change something on a proxy (directly or through any API), it will immediately create a shallow copy of the node in the source tree it is related to, and sets a flag “modified”. From now on, any future read and write to that proxy will not end up in the source tree, but in the copy. Also, any parent that was unmodified so far will be marked “modified”.
When the producer finally ends, it will just walk through the proxy tree, and, if a proxy is modified, take the copy; or, if not modified, simply return the original node. This process results in a tree that is structurally shared with the previous state. And that is basically all there is to it.
Proxies are available in all recent browsers. But still not everywhere. The most notable exceptions are Microsoft Internet Explorer and React Native for Android. For these targets Immer ships with a pure ES5 implementation. Semantically the same, just a bit slower. You can use it by using
import produce from "immer/es5".
There is no real reason to not use Immer from a performance perspective. As pointed out in the benchmarks; Immer is roughly as fast as ImmutableJS, and twice as slow as an efficient, handcrafted reducer. Which is a negligible difference. The ES5 implementation is a lot slower though, so you might want skip Immer for really expensive reducers on those targets (reducers that process tens of thousands of objects). Luckily Immer is entirely opt-in, and you can decide per reducer or action whether you want to use it or not.
The usual mantra holds here: It is always better to optimize for Developer Experience then for Runtime Performance, unless proven by measurements that you need to do otherwise.
Immer started out as little experiment to play with Proxies, at a Mendix research day. (At Mendix all developers are expected sharpen their skills two days a month, in any way they seem fit. Sound cool? We’re hiring). Anyway, it demonstrates how easy it can be as a company to contribute to OSS when providing a little freedom to developers. Immer collected over a thousand github stars already in it’s first week, without any official announcement before this one.
- Strongly typed; if your state object has a type, you will get full assistance based on that
- Structural sharing out of the box
- Object freezing out of the box
- Significant boilerplate reduction. Less noise, more concise code
Credits: Thanks Matt Ruby for proof-reading, and Justin Hedani for the awesome illustrations