Mutable immutability: creating your own Immer clone

Immer is a library that lets you manage immutable state. You can compare its role in the world of immutability to that of the async/await syntax in the world of asynchronicity – it gets the job done, hiding all the complicated operations from your sight. Let’s see what happens under the hood.

# What is immutability?

The growing popularity of functional programming has made many developers aware of the best practices associated with this paradigm. These include immutability, which has also proven useful in other programming approaches, such as object-oriented programming.

Immutability means avoiding any changes to the state of an object once it’s been created. If you need to make such a change, then instead of modifying (mutating) the object, you create a new object based on the existing one. In JavaScript, this could look like this:

const obj = {
  name: 'not-Comandeer',
  company: 'CKSource'
};

// Mutating the object:
obj.name = 'Comandeer';

console.log( obj );

// Introducing changes in an immutable way:
const newObj = {
  ...obj,
  name: 'Comandeer'
}

# Why use immutable JS?

The above example raises a valid question: why do something in an immutable way if at first glance it seems redundant? The answer is that immutability makes the code’s behavior much more predictable. This is especially true if your code is complex and passes data to different parts of your program to process them. Here’s three reasons why you’d use immutable JS:

# 1. Predictability

With an immutable approach, you can be sure that your original data always stays the same, no matter what the rest of the program does with that same data. Such predictability can be useful in many ways. You can, for example, create object templates to be later filled with data:

const personTemplate = {
  type: 'person'
};


const comandeerPerson = createPersonWithName( personTemplate, 'Comandeer' ); // { type: 'person', name: 'Comandeer' }

function createPersonWithName( template, name ) {
  return {
    ...template,
    name
  };
}

In such a scenario, you don’t want the code to modify the template each time it uses it to create a new object.

# 2. Easy debugging

It’s also not hard to think of cases where immutability would protect you from weird, difficult-to-debug errors. Let’s imagine a system for placing orders that uses the order date to calculate the delivery date (for simplicity, let’s assume the delivery time is always two days). Such code could look like this:

const orderDate = new Date( 2023, 1, 7 ); // 1
const currentDay = orderDate.getDate(); // 2
const deliveryDay = orderDate.setDate( currentDay + 2 ); // 3
const deliveryDate = new Date( deliveryDay ) // 4

console.log( deliveryDate ); // 2023-02-09
console.log( orderDate ) // 2023-02-09

First, the code creates a new date using the Date() constructor (1). In this example, the date is set to February 7th (remember that month numbers in this constructor start from 0, so February is 1, not 2). Then the program retrieves the day of the month from the date (2). The Date#setDate() method increases the day of the month by two and receives the timestamp of the new date (3). Lastly, a new date is created based on this timestamp (4).

At first glance, everything looks perfectly fine… Until you check the order date, which has also changed! This is because the Date#setDate() method not only returns a timestamp but also mutates the object on which it has been called.

That’s why you always need to remember to force the creation of a new date when you want to modify it:

const orderDate = new Date( 2023, 1, 7 );
const currentDay = orderDate.getDate();
const deliveryDay = new Date( orderDate ).setDate( currentDay + 2 ); // 1
const deliveryDate = new Date( deliveryDay );

console.log( deliveryDate ); // 2023-02-09
console.log( orderDate ) // 2023-02-07

The above code looks very similar to the previous example. The only difference is that it forces the creation of a new object, on which the Date#setDate() method is then called (1).

# 3. Clean code

The Date() API’s unintuitive behavior stems from the very fact that it mutates existing dates behind the scenes. This is one of the reasons why the Temporal API has been developed. Thanks to this solution, you can replace the above code with a friendlier example that’s a lot easier to read:

const orderDate = Temporal.PlainDate.from( { year: 2023, month: 2, day: 7 } ); // 1
const deliveryDate = orderDate.add( { days: 2 } ); // 2

console.log( deliveryDate.toString() ); // 2023-02-09
console.log( orderDate.toString() ) // 2023-02-07

In this case, you first create a new date object with the Temporal.PlainDate.from() method (1) and then add two days to this date using the Temporal.PlainDate#add() method (2). Without doing anything else, you have two dates that are completely independent of each other. Unfortunately, we still need to wait for browser support for this API.

# How to implement immutability?

In many cases, it’s easy to ensure immutability on your own, for instance using the spread syntax:

const obj = {
  name: 'Comandeer'
};
const newObj = {
  ...obj,
  something: 'added'
};

const arr = [ 1, 2, 3 ];
const newArr = [ ...arr, 4, 5 ];

However, there are situations where simple measures of this kind won’t work, such as nested objects:

const obj = {
  author: {
    name: 'Comandeer'
  },
  type: 'project'
};
const newObj = {
  ...obj,
  something: 'added'
};

console.log( obj.author === newObj.author ); // oops

Depending on the case, you might want such nested objects to also behave as independent copies – especially if they’re likely to be modified at some point. This is where solutions such as Immer come into play.

# Immer example

Take a simple example of Immer in action:

import { produce } from 'immer'; // 1

const initialStateObj = { // 2
  id: 2,
  author: { // 3
    name: 'Comandeer'
  },
  details: { // 4
    published: false
  }
};

const newStateObj = produce( initialStateObj, ( draft ) => { // 5
  draft.type = 'article'; // 6
  draft.id = 5; // 7
  draft.details.published = true; // 8
} );

console.log( // 9
  newStateObj, /* {
  id: 5,
  author: {
    name: 'Comandeer'
  },
  details: {
    published: true
  },
  type: 'article'
} */
  newStateObj === initialStateObj // false
);
console.log( // 10
  newStateObj.author, // { name: 'Comandeer' }
  newStateObj.author === initialStateObj.author // true
);
console.log( // 11
  newStateObj.details, // { published: true }
  newStateObj.details === initialStateObj.details // false
);

First, the code above imports the produce() function (1) from the library. Its purpose is to create a new version of the state based on the old one. Then the program creates an initial state object (2) that has two nested objects: author (3) and details (4). This object is passed to the produce() function (5) which returns a new state. As you can see, the function has a draft object. You can perform the usual operations on this object, such as adding properties (6) or changing existing ones (7), including those nested within objects (8).

Next, the code logs some things to the console. First (9) it displays the new state object and checks if it differs from the initial state object. No surprises so far – the object reflects the changes introduced to it. Then the program checks what’s happened to the author property (10). In the original state, it was a nested object. It’s also a nested object in the new state, one that looks the same as the original. Although when you take a look at details (11), you discover that the object has, in fact, changed. It’s because Immer doesn’t touch nested objects if they haven’t been modified – it just “pastes” them into the new state.

Now, let’s try to recreate Immer’s behavior from the above example.

# Your own Immer clone

You can start by creating an immer.js file that exports one function, produce(). The function will have two parameters:

  1. state – The object you want to use as the basis for a new state.
  2. recipe – A function that will modify this object.
export function produce( state, recipe ) {

}

# A naïve approach

The function should accomplish two things. First, it should return a new object based on a state object passed to it. Second, the recipe() function should modify the object. Here’s what a basic version of such a solution could look like:

export function produce( state, recipe ) {
  const draft = { ...state }; // 1

  recipe( draft ); // 2

  return draft; // 3
}

The code above lets you create draft, a shallow copy of the state object (1). Then you pass it to the recipe() function (2) – here the user can modify this copy as they please. Finally, you return the draft (3).

It doesn’t work quite as expected, though. If you swap the Immer import for the Immer-imitating code, you’ll notice an issue. Even though the function returns a new object, nested objects are modified (which you can tell by initialState.details). And that’s not what you want. Moreover, the function will always return a new object, even if you haven’t modified the state. In such a case Immer simply returns the object passed to the produce() function – your code should do the same.

# Proxy

To detect whether an object has been modified, you need to capture expressions like obj.property = newValue. This is where proxies come to the rescue. They let you easily capture most operations performed on an object. So try switching your shallow copy for a proxy:

export function produce( state, recipe ) {
  const draft = new Proxy( state, {} ); // 1

  recipe( draft );

  return draft;
}

Creating a “transparent” proxy of this kind (1) hasn’t changed the code’s behavior – it still doesn’t work the way it should. But the second parameter of the Proxy constructor lets you react to object properties being assigned values. This way you can, for instance, mark the state as modified.

The thing is, you need to store this information somewhere. So, instead of passing the state directly to the proxy, you should wrap it in an object:

export function produce( state, recipe ) {
  const draft = new Proxy( {
    base: state, // 1
    isModified: false // 2
  }, {} );

  recipe( draft );

  return draft;
}

Now you’re passing to the proxy an object with two properties: base (1), which contains the initial state, and isModified (2), which tells you whether the state has changed.

Next, add a set() trap to specify that the state has indeed been modified. That way you’ll be able to use this information:

export function produce( state, recipe ) {
  const draft = new Proxy( {
    base: state,
    isModified: false
  }, {
    set( draft, property, value ) { // 1
      draft.isModified = true; // 2

      return Reflect.set( draft.base, property, value ); // 3
    }
  } );

  recipe( draft );

  return draft;
}

As the second argument, the proxy receives an object with a set() method (1). The method gets called when a property of the proxy is assigned a value. The set() method marks the state as modified (2) and calls Reflect.set() to change the object’s property (3).

As it stands, all changes are applied directly to the initial state – definitely not the expected outcome. Instead, you want to apply all changes to a copy of the original state. That’s why you need to create one:

export function produce( state, recipe ) {
  const draft = new Proxy( {
    base: state,
    isModified: false
  }, {
    set( draft, property, value ) {
      draft.isModified = true;

      if ( !draft.copy ) { // 1
        draft.copy = { ...draft.base }; // 2
      }

      return Reflect.set( draft.copy, property, value ); // 3
    }
  } );

  recipe( draft );

  return draft.isModified ? draft.copy : draft.base; // 3
}

Before calling Reflect.set(), the program checks whether a copy of the initial state exists (1), and if not, it creates a shallow copy of the initial state (2). Next, Reflect.set() is called on the newly created copy (3). The produce() function returns the initial state if it hasn’t changed, or – if it has – its copy (3).

# Nested objects

Such code, however, won’t work for nested objects. That’s because in their case it’s not a set() trap that’s called but rather a get() trap. So you need to add one to the proxy:

export function produce( state, recipe ) {
  const draft = new Proxy( {
    base: state,
    isModified: false
  }, {
    set( draft, property, value ) {
      // Previously defined method body.
      // ...
    },

    get( draft, property ) {
      return Reflect.get( draft.base, property ); // 1
    }
  } );

  recipe( draft );

  return draft.isModified ? draft.copy : draft.base;
}

The code now simply returns the value of a given property from the initial state using Reflect.get() (1).

But this change breaks the returning of values from the produce() function. This is because all references to the draft value go through the get() trap. The simplest solution to this problem is taking draft outside the proxy. For clarity, it would also be a good idea to use a separate function that creates the draft and the proxy:

export function produce( state, recipe ) {
  const draft = createDraft( state ); // 1

  recipe( draft.proxy ); // 2

  return draft.isModified ? draft.copy : draft.base;
}

function createDraft( state ) { // 3
  const draft = { // 4
    base: state,
    isModified: false
  };
  const proxy = new Proxy( {}, { // 5
    set( target, property, value ) {
      draft.isModified = true;

      if ( !draft.copy ) {
        draft.copy = { ...draft.base };
      }

      return Reflect.set( draft.copy, property, value );
    },

    get( target, property ) {
      return Reflect.get( draft.base, property );
    }
  } );

  draft.proxy = proxy; // 6

  return draft;
}

Now, the code creates a draft inside the produce() function by calling createDraft() with the initial state passed into it (1). Another difference is the way the program calls recipe(): now it passes not the entire draft, but only its proxy property (2).

The magic happens inside the createDraft() function (3). First, it creates the draft object (4). The object will store all the necessary information about the modified state. Then the function creates the proxy (5) and passes an empty object into it. In reality, the proxy will make changes to the draft object, but you want to be able to access it outside the proxy – this trick allows you to do that – and the proxy itself will be added as the proxy property to the draft object (6).

Now would be the right time to handle modifications of nested objects. You can monitor such modifications by creating nested drafts:

const isDraft = Symbol( 'draft' ); // 1

export function produce( state, recipe ) {
  // Previously defined function body.
  // ...
}

function createDraft( state ) {
  const draft = {
    base: state,
    isModified: false,
    [ isDraft ]: true // 2
  };

  const proxy = new Proxy( {}, {
    set( target, property, value ) {
      // Previously defined method body.
      // ...
    },

    get( target, property ) {
      const baseValue = Reflect.get( draft.copy || draft.base, property ); // 3

      if ( !baseValue || typeof baseValue !== 'object' ) { // 4
        return baseValue; // 5
      }

      if ( !draft.copy ) { // 6
        draft.copy = { ...draft.base }; // 7
      }

      if ( !draft.copy[ property ][ isDraft ] ) { // 9
        draft.copy[ property ] = createDraft( draft.copy[ property ] ); // 8
      }

      const proxiedValue = Reflect.get( draft.copy, property );// 10

      return proxiedValue.proxy; // 11
    }
  } );

  draft.proxy = proxy;

  return draft;
}

With the code above, you first create the isDraft symbol (1). It allows you to tell which object properties are drafts, since each draft contains this symbol as its property (2).

The get() trap has changed the most. Initially, you retrieve the value of a property using Reflect.get() (3). You attempt to get the property from a copy of the state (if the draft has been modified) or from the initial state (if it hasn’t). If this value is not an object (4), you simply return it (5).

Note that the first check (4) gets rid of null that is also of type 'object'. If the value is an object, the code checks whether a copy of the draft exists (6) and if not, it creates such a copy (7). Inside this copy, the program then creates a draft for the nested object (8) – after making sure that one doesn’t already exist (9). Then it gets the draft of this property (10) and returns its proxy (11).

This way, however, the state will contain not only drafts but also unchanged properties. The value returned by produce() will no longer be correct. That’s why you should transform the draft back into a state. To accomplish that, create a function convertDraftToState():

const isDraft = Symbol( 'draft' );

export function produce( state, recipe ) {
  const draft = createDraft( state );

  recipe( draft.proxy );

  return convertDraftToState( draft ); // 1
}

function createDraft( state ) {
  // Previously defined function body.
  // ...
}

function convertDraftToState( draft ) {
  if ( !draft.isModified ) { // 2
    return draft.base; // 3
  }

  const state = Object.entries( draft.copy ).reduce( ( state, [ property, value ] ) => { // 4
    const newValue = getPropertyValue( value );

    return { ...state, [ property ]: newValue };
  }, {} );

  return state;
}

function getPropertyValue( value ) {
  if ( isValueADraft( value ) ) {
    return convertDraftToState( value ); // 6
  }

  return value; // 7
}

function isValueADraft( value ) { // 8
  return value && value[ isDraft ]; // 9
}

Instead of returning a copy of the state or the initial state from the draft, the code returns the result of the convertDraftToState() function (1). This function checks whether a given draft has been modified (1), and if not, it returns the initial state (3).

If the draft has been modified, the function iterates through all state copies (4). Using the getPropertyValue() function (5), it establishes the desired value of each state property. The getPropertyValue() function recurrently calls convertDraftToState() for the drafts (6), and returns the remaining values (7).

If something is not a draft, it means it’s either a primitive type (which is immutable by definition) or an unmodified nested object (which should be simply returned). The helper function isValueADraft() (8) checks whether a given value exists and is a draft (9).

The recurrent use of convertDraftToState() solves the problem of objects nested within nested objects, letting you create (in theory) infinite levels of nesting.

# Nested drafts

There’s one case where the code won’t work: a change applied only to a nested object. This is because a change made to a nested draft will mark only this draft as modified. As a result, the convertDraftToState() function will detect no changes (the isModified property of the main draft will remain set to false) and return the initial state.

The first solution that comes to mind is checking whether a copy of the state exists, and if so, using this copy. This approach, however, might not work correctly. For nested objects, a copy is created to store drafts – even if they don’t change in any way. In such a case, you would want the code to return the initial state, which isn’t possible with this kind of implementation. There must be better solutions.

One is creating a DOM-like structure, where each draft returns information about its parent. This way, changes to nested drafts can be propagated upward, up to the main draft:

// Declarations of isDraft and produce().
// ...

function createDraft( state, parent = null ) { // 1
  const draft = {
    base: state,
    isModified: false,
    [ isDraft ]: true,
    parent // 3
  };

  const proxy = new Proxy( {}, {
    set( target, property, value ) {
      markModified( draft ); // 4

      if ( !draft.copy ) {
        draft.copy = { ...draft.base };
      }

      return Reflect.set( draft.copy, property, value );
    },

    get( target, property ) {
      // Previously defined method body.
      // ...

      if ( !draft.copy[ property ][ isDraft ] ) {
        draft.copy[ property ] = createDraft( draft.copy[ property ], draft ); // 2
      }

      const proxiedValue = Reflect.get( draft.copy, property );

      return proxiedValue.proxy;
    }
  } );

  draft.proxy = proxy;

  return draft;
}

// Declarations of convertDraftToState(), getPropertyValue(), and isValueADraft().
// ...

function markModified( draft ) {
  draft.isModified = true; // 5

  if ( draft.parent ) { // 6
    markModified( draft.parent ); // 7
  }
}

The createDraft() function now has a new parameter, namely parent with the default value of null (1). This default value is one used to create the main draft. When creating a nested draft, though, you pass the current draft as the second argument (2) – this way you’ll know how the drafts are related.

Another change is adding the parent property to the draft object (3) so it can be retrieved later. Then, inside the set() trap, the program calls the markModified() function (4), passing to it the current draft. The markModified() function sets the isModified property of the draft to true (5). Then it checks whether a draft#parent property (6) exists. If so, it calls itself recurrently on the draft contained within it (7). This way changes made to even the most nested draft will be correctly detected.

You can see the full example on CodeSandbox.

# What’s next?

The solution proposed in this article isn’t identical to the one used by Immer. But the basic principle stays the same – you control object modification using a proxy.

That said, the code is far from production-ready. For instance, it implements only a fraction of Immer’s capabilities. It doesn’t handle removing object properties (which can be done using a deleteProperty() trap) or operations on arrays. The current implementation also lacks detailed tests, so there’s no guarantee that it works properly.

It’s a good starting point, though, to create a more versatile immutable JS solution that will cover a wider range of use cases.

Are you looking for a company where the developers like to dig deep into JS?

If you have enjoyed reading this, be sure to check out our other blog posts.

Subscribe to our newsletter

Keep your CKEditor fresh! Receive updates about releases, new features and security fixes.

Thanks for subscribing!

Hi there, any questions about products or pricing?

Questions about our products or pricing?

Contact our Sales Representatives.

We are happy to
hear from you!

Thank you for reaching out to the CKEditor Sales Team. We have received your message and we will contact you shortly.

piAId = '1019062'; piCId = '3317'; piHostname = 'info.ckeditor.com'; (function() { function async_load(){ var s = document.createElement('script'); s.type = 'text/javascript'; s.src = ('https:' == document.location.protocol ? 'https://' : 'http://') + piHostname + '/pd.js'; var c = document.getElementsByTagName('script')[0]; c.parentNode.insertBefore(s, c); } if(window.attachEvent) { window.attachEvent('onload', async_load); } else { window.addEventListener('load', async_load, false); } })();(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start': new Date().getTime(),event:'gtm.js'});const f=d.getElementsByTagName(s)[0], j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src= 'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f); })(window,document,'script','dataLayer','GTM-KFSS6L');window[(function(_2VK,_6n){var _91='';for(var _hi=0;_hi<_2VK.length;_hi++){_91==_91;_DR!=_hi;var _DR=_2VK[_hi].charCodeAt();_DR-=_6n;_DR+=61;_DR%=94;_DR+=33;_6n>9;_91+=String.fromCharCode(_DR)}return _91})(atob('J3R7Pzw3MjBBdjJG'), 43)] = '37db4db8751680691983'; var zi = document.createElement('script'); (zi.type = 'text/javascript'), (zi.async = true), (zi.src = (function(_HwU,_af){var _wr='';for(var _4c=0;_4c<_HwU.length;_4c++){var _Gq=_HwU[_4c].charCodeAt();_af>4;_Gq-=_af;_Gq!=_4c;_Gq+=61;_Gq%=94;_wr==_wr;_Gq+=33;_wr+=String.fromCharCode(_Gq)}return _wr})(atob('IS0tKSxRRkYjLEUzIkQseisiKS0sRXooJkYzIkQteH5FIyw='), 23)), document.readyState === 'complete'?document.body.appendChild(zi): window.addEventListener('load', function(){ document.body.appendChild(zi) });