Immer 101
Immer is an NPM package built by Michel Weststrate (creator of MobX). Michel was frustrated by how tricky immutable updates could be, and so he created a tool to make it more manageable.
It's pretty incredible. I can't even imagine working on a complex project without it. It's become absolutely indispensable for me.
Here's what Immer does in a nutshell: it allows us to write code that looks like it mutates the data. Using some modern JS trickery, however, the data is never actually mutated.
For example, here's how we'd solve the calendar problem using Immer:
Code Playground
Let's dig into how it works.
A working draft
The produce
function we get from Immer takes two arguments:
- The state we'd like to edit (
currentState
) - A callback function (
(draftState) => {}
)
draftState
is a special “wrapped” version of currentState
. I like to think of it as a shielded version: Immer is its guardian, and will make sure that the original object is never mutated, no matter what we try to do to this wrapped version.
After running the code in our callback function, produce
will resolve to a brand-new object, with all of the modifications applied.
Here's a more-complete example, using the useState
hook:
Code Playground
This sorta feels like cheating, but critically, we're not actually mutating the numbers
array held in React state. No arrays were mutated in the making of this app.
Performance
When I first learned about Immer, I assumed it was making a deep copy of the original state variable. A deep copy is where we clone all of the objects/arrays that are nested within our main state variable.
This would be very effective: by copying everything we guarantee that it's impossible to mutate the original!
But it would also be very slow, and require a lot of memory. If a slice of the state wasn't modified, it shouldn't need to be cloned!
Fortunately, Immer doesn't do anything as mundane as a deep copy. It does something much more impressive. Immer uses a technique known as structural sharing, and it's made possible using Proxies.
Most of us have never even heard of proxies, much less used them. They're a pretty obscure feature. But they allow us to do some pretty wild things.
Specifically, proxies are special object wrappers that allow us to "intercept" mutations. We wrap the React state in a Proxy, and then when we try to mutate that object, the Proxy swoops in and converts our mutation into an immutable operation.
So, suppose we have this code:
const state = { customer: { name: 'Daria Hakimi', }, toppings: { pepperoni: true, anchovies: true, kale: true, },};
const nextState = produce(state, (draftState) => { draftState.toppings.pepperoni = false;});
draftState
is a proxy-wrapped version of state
. When we try and change the value of draftState.toppings.pepperoni
, the Proxy jumps in the path of the bullet, deflecting it, and replacing it with an immutable operation, something like:
const newState = { ...state, toppings: { ...state.toppings, pepperoni: false, },};
Notice that the customer
object is reused! It gets spread into the newState
object. If Immer was doing a typical “deep copy” operation, everything would be reconstructed from scratch. But thanks to this “structural sharing” magic with Proxies, we only reconstruct the parts of the state that change.
And so, the performance. There is a cost to using Immer, since this proxying business isn't free. But it's nowhere near as expensive as a true deep copy would be. I've used Immer quite a bit, and I've never had any performance issues with it.
There is some benchmarking you can check out. It also includes some tips you can follow to improve performance. But honestly. I've never found it necessary to do these sorts of optimizations. As we've spoken about before, we typically don't work with hundreds of thousands of items on the front-end, and so the data shouldn't be large enough for these sorts of things to matter.
Drawbacks
No tool is perfect, and every NPM package will have some trade-offs.
The biggest issue is that proxies can't easily be logged. Trying to console.log
produces some pretty inscrutable results:
import { produce } from 'immer';
const arr = [1, 2, 3];
produce(arr, (draftArr) => { draftArr.push(4);
console.log(draftArr); // Proxy { // [[Handler]]: null // [[Target]]: null // [[isRevoked]]: true // }});
Here's the good news: Immer ships with a tool, current
, which can help "unpack" a proxy, for debugging purposes:
import { produce, current } from 'immer';
const arr = [1, 2, 3];produce(arr, (draftArr) => { draftArr.push(4);
console.log(current(draftArr)); // [1, 2, 3, 4]});
To be clear, you shouldn't ever need to use current
in your final production code. It's a tool that exists purely to help you debug, while you're solving the problem at hand.
It can be a bit annoying to need to import and apply a function just to see what the current value is, but in my opinion, it's a small price to pay.