Introducing Immer: Immutability the easy way

Written by mweststrate | Published 2018/01/01
Tech Story Tags: react | immutable | redux | javascript | immer | latest-tech-stories | introducing-immer | immer-immutability

TLDR Immutable data structures are a great paradigm for storing state. But in a language like JavaScript, producing a new state from the previous one is a boring, boiler-platy task. Introducing Immer: Immutability the easy way to use persistent data structures in ReactJS. Immer works by writing producers, and the simplest producer possible looks like this: const nextState = produce(currentState, draft) The next state is simply the state we started with. All changes to draft are reflected in the next state, which structurally shares untouched items.via the TL;DR App

Immutable, structurally shared data structures are a great paradigm for storing state. Especially when combined with an event-sourcing architecture. However, there is a cost to pay. In a language like JavaScript where immutability is not built into the language, producing a new state from the previous one is a boring, boiler-platy task. To prove the point: The Redux-ecosystem-links page alone lists 67(!) packages to help you to deal with immutable data structures in Redux.
And still; most of them don’t solve the root problem: lack of language support. For example, where update-in is an elegant concept in a language like ClojureScript, any JavaScript counterparts will basically rely on ugly string paths. Which are error-prone, hard to type-check and requires learning yet another set of API functions by heart to be pro-efficient.
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
immer
does.
Tip: if you don’t like reading, you can also watch the egghead tutorial for immer

Producers

Immer works by writing producers, and the simplest producer possible looks like this:
import produce from "immer"

const nextState = produce(currentState, draft => {
  // empty function
})

console.log(nextState === currentState) // true
(A minimal (empty) producer will return the original state)
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.
import produce from "immer"

const todos = [ /* 2 todo objects in here */ ]

const nextTodos = produce(todos, draft => {
    draft.push({ text: "learn immer", done: true })
    draft[1].done = true
})

// old state is unmodified
console.log(todos.length)        // 2
console.log(todos[1].done)       // false

// new state reflects the draft
console.log(nextTodos.length)    // 3
console.log(nextTodos[1].done)   // true

// structural sharing
console.log(todos === nextTodos)       // false
console.log(todos[0] === nextTodos[0]) // true
console.log(todos[1] === nextTodos[1]) // false
(A real producer. All changes to draft are reflected in the next state, which structurally shares untouched items with the previous state)
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.
// Shortened, based on: https://github.com/reactjs/redux/blob/master/examples/shopping-cart/src/reducers/products.js
const byId = (state, action) => {
  switch (action.type) {
    case RECEIVE_PRODUCTS:
      return {
        ...state,
        ...action.products.reduce((obj, product) => {
          obj[product.id] = product
          return obj
        }, {})
      }
    default:      
      return state
  }
}
(A typical Redux reducer)
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:
const byId = (state, action) =>
  produce(state, draft => {
    switch (action.type) {
      case RECEIVE_PRODUCTS:
        action.products.forEach(product => {
          draft[product.id] = product
        })
        break
    }
  })
(Simplifying the reducer by using Immer)
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 idea to produce the next immutable state by modifying a temporarily draft isn’t new. For example immutableJS provides a similar mechanism: withMutations. The big advantage of Immer however, is that you don’t have to learn (nor load) an entire new library for your data structures. Immer operates on normal JavaScript objects and arrays.
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.
// ImmutableJS
const newMap = map.updateIn(['inMap', 'inList'], list => list.push(4))

// Immer
draft.inMap.inList.push(4)
(Immer remains typed doing deep updates)
Immer doesn’t suffer from any of that; it operates on built-in JavaScript structures. Perfectly understood by any type-checker. And modifying data is done through APIs you are already familiar with; the ones built into the language.

Auto freeze

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.

Currying

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:
const byId = produce((draft, action) => {
  switch (action.type) {
    case RECEIVE_PRODUCTS:
      action.products.forEach(product => {
        draft[product.id] = product
      })
      break
  }
})
(A curried producer (also see the earlier listing for comparison))
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 producer’s source tree and the draft tree shadowing it)
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.
(High level overview of the internal decisions in a proxy. Delegating to either the base tree or a cloned node of the base tree.)
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.

No proxies?

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
".

Performance?

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.

Closing words

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.

TL;DR

Immer has a few quite unique features to offer when it comes to working with immutable data in JavaScript. Basically because it doesn’t fight the language, but embraces it.
  • Immer enables you to use standard JavaScript data structures and APIs to produce immutable states
  • 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
So, star it! Play with it, and use it to your advantage. And, since there are already 67+ immutability libraries for JavaScript….
Credits: Thanks Matt Ruby for proof-reading, and Justin Hedani for the awesome illustrations

Published by HackerNoon on 2018/01/01