Under the hood explanation of Immer library.

Aneesh VaradanAneesh Varadan
7 min read

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.


0
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))