Under the hood explanation of Immer library.


Immer (German for: always) is a tiny package that allows you to work with immutable state in a more convenient way.
-- Immer Documentation
Immer uses Proxies under the hood and therefore to understand the details of this library we need to know a little about proxies. So..
What is a Proxy? Let’s refer to javascript.info
A
Proxy
object wraps another object and intercepts operations, like reading/writing properties and others
The syntax to create a Proxy:
let proxy = new Proxy(target, objectTrap)
Notice in the above snippet I have introduced a term called objectTrap
. We need to understand it because it is one of the key ideas used by the library to do its deeds. So..
Let’s google what does a object trap in proxy do?
In the context of JavaScript Proxies, a "trap" refers to a handler function that intercepts and modifies how specific operations are performed on the target object.
An example to understand how exactly this works.
let user = {
name: "John",
_password: "***"
};
user = new Proxy(user, {
get(target, prop) {
if (prop.startsWith('_')) {
throw new Error("Access denied");
}
return target[prop];
},
});
In the above snippet, user
is born as an object but is eventually christened into a proxy with an objectTrap
that has a get
function defined on it. Now, when we call user[“_password”]
, the get method inside the objectTrap will be triggered and give us an error. I hope you all get the basic idea of proxy.
Now, let’s look at the relevant objectTrap code in the library:
const objectTraps = {
get(target, prop) {
if (prop === isProxySymbol) return target;
return createProxy(target)[prop];
},
set(target, prop, value) {
const current = createProxy(target)[prop];
const newValue = createProxy(value);
if (current !== newValue) {
const copy = getOrCreateCopy(target);
copy[prop] = isProxy(newValue) ? newValue[isProxySymbol] : newValue;
}
return true;
}}
For now, all that we must understand is that the above objectTrap is overriding reading and writing of object properties via the get and set methods and these methods are purely working with proxies rather than the direct objects which we provide to the library. Now, it is time to look at an example code which uses immer to mutate an object:
const baseState = {
name: "John",
age: 20,
address: {
city: "New York",
},
};
const thunk = (draft) => {
draft.name = "Jane";
draft.address.city = "Los Angeles";
draft.newKey = "newKey";
};
const newState = immer(baseState, thunk);
So, let’s try to understand the above code which uses Immer to mutate an object.
Here, we are trying to update the name and city which is a property inside address and add a new property called newKey. Notice that the callback provided to the immer function directly mutates the draft object. This is the power of the library-it allows us to update the object directly, without having to create a copy as we typically do when updating a React state object.
Let’s now dig into the immer
function.
The immer
function first creates a proxy object from the given baseState and here is the library code that does that:
const rootClone = createProxy(baseState);
let’s look at the implementation of createProxy
:
function createProxy(base) {
if (isPlainObject(base) || Array.isArray(base)) {
if (isProxy(base)) return base; // avoid double wrapping
if (proxies.has(base)) return proxies.get(base);
const proxy = new Proxy(base, objectTraps);
proxies.set(base, proxy);
return proxy;
}
return base;
}
So, createProxy
creates a proxy for the base state and adds it to a private map called proxies
. This is used by the library to track all the proxies that have been created.
After createProxy
the library invokes the thunk
function like so:
thunk(rootClone);
So, what is happening?
rootClone
, which is a proxy, is passed to the callback we provided to the Immer function. For now, it may not be clear what is happening, so let's look at the last function that Immer calls and then come back here:
return finalize(baseState);
The library is invoking the final function with baseState which is confusing. At this point, I had the following question in my mind:
How does it track all the changes made to the proxy and how does it merge with the baseState?
Its time, let’s take a look at what happens when we invoke the thunk
with the proxy called rootClone
Take a look at the thunk again:
const thunk = (draft) => {
draft.name = "Jane";
draft.address.city = "Los Angeles";
draft.newKey = "newKey";
};
So, the draft
object that the library passes to the thunk is a proxy. We also know that when we access or assign a property on the proxy, the object trap is invoked. For example, in draft.name
= "Jane";
, we are assigning a property, so the set
trap is triggered. Let’s take another look at the set
trap:
set(target, prop, value) {
const current = createProxy(target)[prop];
const newValue = createProxy(value);
if (current !== newValue) {
const copy = getOrCreateCopy(target);
copy[prop] = isProxy(newValue) ? newValue[isProxySymbol] : newValue;
}
return true;
}
For draft.name
= "Jane";
let’s log the values of target
, prop
, value
.
console.logs:
target { name: 'John', age: 20, address: { city: 'New York' } }
prop name
value Jane
Let’s walk through the code of set
trap. First, it creates a proxy for the target object and looks up its name property
and then assign it to current
and then creates a proxy for value
and assign it to newValue
If current
and newValue
are different it invokes getOrCreateCopy
on target
and assign it to copy
. So let’s stop here and dig a little deeper on the implementation details of the function getOrCreateCopy
Implementation of getOrCreateCopy
in the library:
function getOrCreateCopy(base) {
let copy = copies.get(base);
if (!copy) {
copy = Array.isArray(base) ? base.slice() : Object.assign({}, base);
copies.set(base, copy);
}
return copy;
}
Notice, the copies
map. copies
along with proxies
keeps track of the changes made to the baseState.
The above function is straightforward: if base
is not found in copies
, a copy of that object is made and copies
is updated.
Now, let’s go back to set
trap. When current
and newValue
is different it creates a copy for that target by invoking getOrCreateCopy
function and updates the given prop value of that copy with the newValue
and returns true.
So what have we learnt till now. Let’s summarise:
immer invokes the thunk by passing it a proxy.
whenever a proxy is created the
proxies
map is updated accordingly.when we try to get or set any property on the proxy, it is intercepted by the corresponding trap functions, which update the
copies
map accordingly.
So, let’s simplify this ongoing agony and assume that the thunk has completed its execution and therefore the proxies
map and copies
map are updated accordingly.
Let’s log the final proxies
after the thunk gets executed:
Map(2) {
{ name: 'John', age: 20, address: { city: 'New York' } } => { name: 'John', age: 20, address: { city: 'New York' } },
{ city: 'New York' } => { city: 'New York' }
}
Similarly, Let’s log the final copies
Map(2) {
{ name: 'John', age: 20, address: { city: 'New York' } } => {
name: 'Jane',
age: 20,
address: { city: 'Los Angeles' },
newKey: 'newKey'
},
{ city: 'New York' } => { city: 'Los Angeles' }
}
Now, let’s again go back to the final function call in the immer function:
return finalize(baseState);
Implementation of finalize:
function finalize(thing) {
if (!hasChanges(thing)) return thing;
const copy = getOrCreateCopy(thing);
Object.keys(copy).forEach((prop) => {
copy[prop] = finalize(copy[prop]);
});
return copy;
}
Implementation of hasChanges:
function hasChanges(base) {
const proxy = proxies.get(base);
if (!proxy) return false; // nobody did read this object
if (copies.has(base)) return true; // a copy was created, so there are changes
// look deeper
const keys = Object.keys(base);
for (let i = 0; i < keys.length; i++) {
const value = base[keys[i]];
if (isPlainObject(value) && hasChanges(value)) return true;
}
return false;
}
Finally, we meet recursion. So, in simple words finalize
checks if there is a change in the given object and peaks into copies
and return the appropriate value by recursively walking through the copy object.
Just read through the implementation of finalize
and hasChanges
, it’s easy to see how they use proxies
and copies
to reconstruct the given baseState
with its updated parts and return it to the user.
That is all for now. This is a hobby blogpost to document by diggings into the source code of various libraries that I have used or come to admire. At the current amount I am trying to understand fixi.js and preact/signals. I hope that I would be able to write a more clear blogpost on these libraries in the future.
Thanks for reading and for being patient. I’m open to any feedback!
AI disclaimer
Have used AI to correct grammatical errors otherwise it’s all me.
Subscribe to my newsletter
Read articles from Aneesh Varadan directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Aneesh Varadan
Aneesh Varadan
(define me (list 'AFreeVariable 'Learn 'Teach 'SoftwareDeveloper 'SmileAndBeHappy))