Typesafe code with Immer and where it can help in NgRx

Post Editor

Immer is a tiny library that uses structural sharing and proxy objects to guarantee the immutability pattern in the most efficient way. It's also very convenient when writing reducers to shorten code and ensure type safety.

10 min read
0 comments
post

Typesafe code with Immer and where it can help in NgRx

Immer is a tiny library that uses structural sharing and proxy objects to guarantee the immutability pattern in the most efficient way. It's also very convenient when writing reducers to shorten code and ensure type safety.

post
post
10 min read
0 comments
0 comments

Immer is a tiny library that makes working with immutable data structures effortless. It does so by using efficient cloning mechanisms and relying on standard JavaScript syntax. In this article we will learn how Immer works, what it's generally used for, and how we can use it to write performant and concise NgRx reducers.

As you probably know, NgRx is a popular state management tool in Angular applications design, but it requires lots of boilerplate. This is especially true for reducers that can be cumbersome when they deal with deeply nested objects.  

Reducers have to be immutable, so it is customary to use the spread operator to clone the object with changes. The operator clones only top level of the object (shallow copy) in exactly the same way as the Object.assign() does. This technique, while widely used, makes mutation of nested objects tricky and can error-prone.

Immer is only a 3KB library that allows you to clone an object like this:

<>Copy
export const initialState: ExampleState = { model2: 'test', levelOne: {levelTwo: {nestedOne: {nestedTwo: ['a'], someData: 'b'}, someData: 'c'}, otherData: 'd'} }; const exampleReducer = createReducer(initialState, // 👇 Clone each level of object with spread operator on(exampleAction, (state, {value}) => ({ ...state, levelOne: { ...state.levelOne, levelTwo: { ...state.levelOne.levelTwo, nestedOne: { ...state.levelOne.levelTwo.nestedOne, nestedTwo: [...state.levelOne.levelTwo.nestedOne.nestedTwo, value] } } } })), // 👇 Nornal JS mutation, Immer ensure the immutability on each level with produce function on(exampleAction, (state, {value}) => produce(state, draft => { draft.levelOne.levelTwo.nestedOne.nestedTwo.push(value); }) ) );
This code snippet shows what kind of problem Immer solves, reduces boilerplate with a simple JS API

The biggest advantage of Immer is that you don’t have to learn a new library, Immer works with normal JavaScript; furthermore, this high-level api guarantees type safety, simplifies the code and improves readability and maintainability.

In addition, when using the spread operator, you may lose type safety if the return type of the reducer function is not specified. This particular problem can be easily solved with Immer, because you don't need to specify the return type, type inheritance works fine out of the box.

If the return type is not specified when writing a normal reducer, typescript will not warn you if the new property does not exist in the interface.

This code snippet demonstrates this behavior (live example can be found here):

<>Copy
export interface Feature1State { books: Book[]; } export const initialState: Feature1State = { books: [] }; // Return type of the on fn. Contains the action reducer coupled to one or more action types. const feature1Reducer = createReducer( initialState, on(getBooksSuccess, (state, { books }) => // 👇 Produce is Immer's function to manage change in an immutable way produce(state, draft => { draft.model1 = books; // ERROR TS2551: Property 'model1' does not exist on type 'WritableDraft '. }) ), on(getBooksSuccess, (state, { books }) => ({ ...state, model1: books // no error => at runtime will create a model1 inside the Feature1State. })), on(getBooksSuccess, (state, { books }): Feature1State => ({ ...state, model1: books // we got the Error only when specifing the return type on each reducer function }) ) );

Timdeschryver the co-creator of NgRx has made a wrapper for Immer called NgRx-Immer which wraps the Immer produce function around NgRx reducer’s functionality. This library is a candidate to be merged into the NgRx ecosystem in the future.

I'm going to spend some time talking about different ways to clone an object, what technique is used by Immer, and how it works under the hood. If you're only interested in how to use it with NgRx reducers, jump straight to the last section of the article.

What is Immer and how does it work?
Link to this section

Immer is a tiny package that allows you to work with immutable objects using regural Javascript syntax. When using Immer, changes are made to a temporary draft object, which is created as a proxy for the original object. In this way changes do not affect the original object.

Immer takes the initial state and generates a temporary draft where you make all changes and once you are done, Immer produces the next state:

The usability is incredibly easy, in fact everything is managed by the produce function that has the following signature:

<>Copy
produce(originalObject, recipe: (draft) => void): clonedObject

where,

  • recipe is a function, that takes a draft object that can be safely mutated, since draft is proxy object for the original one (using draft as a name is just a convention to signal "mutation is OK here")

It's possible to call the produce function with only one argument, which would be a producer function of the form (originalObject, ...arguments) => mutatedObject. It take the originalObject and an arbitrary amount of additional arguments. This technique is called currying and sometimes greatly helps reduce the boilerplate.

Shallow-copy, deep-copy and structural-sharing
Link to this section

Before going into the details of how Immer works, I think it is important to briefly review the main differences between shallow clone, deep clone and structural sharing:

  • shallow copy (shallow cloning) copies only one level of an object preserving the references to nested objects, hence if you mutate the property that points to a nested object, the cloned object will reflect the changes. Shallow copy can be performed with Object.assign or spread (...) operator.
  • deep copy (deep cloning) can be achieved with a recursive algorithm that clones every property of the object, even if these properties won't need to be changed. For nested objects it creates new objects in memory with new references. This is, for example, what the cloneDeep function from Lodash library does. The deep clone is very expensive and is not really recommended for a general use.
  • structural sharing is different from deep cloning because with this technique only the changed part of the data structure will be cloned (regardless of the nesting level). When we assign a new value to a property, the process known as copy-on-write takes place. When a property changes, all its parents will also be shallowly cloned, but the unmodified nested object will keep the original reference. For this reason it is common to freeze the entire data structure to make sure it doesn't break the immutability contract (a direct mutation of the object is forbidden).

Immer uses structural sharing with proxies to perform an efficient clone.

Going deep into the Immer world
Link to this section

We know that Immer doesn’t clone all references inside the main object, but only what is affected by the changes to the draft object (Structural-Sharing, Copy-on-write). The draft is a revocable Proxy object of the initial state. The proxy object is a low-level ES6 feature that allows you to create a proxy from another object, which can intercept and redefine key operations like this:

<>Copy
const target = { message1: "hello", message2: "everyone" }; const traps = { get: function(target, prop, receiver) { if(Object.prototype.hasOwnProperty.call(target, prop)){ return "property exist, hello! from proxy"; } return undefined; } }; const {proxy, revoke} = Proxy.revocable(target, traps); console.log(proxy.message2) // "property exist, hello! from proxy" console.log(proxy.message3) // undefined revoke(); console.log(proxy.message2) // Cannot perform 'get' on a proxy that has been revoked
this code snippet shows how a proxy object works

An high level overview of the Immer internal decisions is summarized in the flow-chart below:

The life cycle of Immer can be summarized like this:

  1. At the start create a Proxy root
  2. Lazily create proxy when a field is dereferenced
  3. Upon write, create shallow clone
  4. Upon finish, combine clones, freeze the objects

I’m going to explain step by step how Immer works with the help of some code extracted  from the library.

At the start when the ‘produce’ function is called, a draft is created as a proxy of the main object. When Immer instantiate a proxy it define a ProxyState where store important information like the scope and the parent proxy state:

<>Copy
export function createProxyProxy<T extends Objectish>( base: T, parent?: ImmerState ): Drafted<T, ProxyState> { const isArray = Array.isArray(base) const state: ProxyState = { type_: isArray ? ProxyType.ProxyArray : (ProxyType.ProxyObject as any), // Track which produce call this is associated with. scope_: parent ? parent.scope_ : getCurrentScope()!, // True for both shallow and deep changes. modified_: false, // Used during finalization. finalized_: false, // Track which properties have been assigned (true) or deleted (false). assigned_: {}, // The parent draft state. parent_: parent, // The base state. base_: base, // The base proxy. draft_: null as any, // set below // The base copy with any updated values. copy_: null, // Called by the `produce` function. revoke_: null as any, isManual_: false } let target: T = state as any let traps: ProxyHandler<object | Array<any>> = objectTraps ... const {revoke, proxy} = Proxy.revocable(target, traps) state.draft_ = proxy as any state.revoke_ = revoke return proxy as any }
this code snippet extracted from the library shows what kind of information is stored by Immer and how the porxy is instantiated

The proxy traps catch all possible operations to the target object.  Accessing an object property triggers the "get" trap which, if the property contains an object, will instantiate another proxy (in this way Immer create a tree of proxy).

A summary of some Immer traps is given in the following:

<>Copy
// summary of the "get" trap function readProperty(base, prop) { const draftState = findOrCreateDraftState(base) const value = draftState.modified ? draftState.copy[prop] : base[prop] if (isProxyable(value)) return getOrCreateProxy(value) return value } // summary of the "set" trap function setProperty(base, prop, value) { const draftState = findOrCreateDraftState(base) if (!draftState.modified) { draftState.modified = true draftState.copy = {...base} markParentsChanged(draftState) } draftState.copy[prop] = value }
This code summarizes the logic contained in the 'get' and 'set' trap functions of the Proxy

The trap set does not modify the target, but creates a shallow copy of the node with the settled value and stores it in the scope variable copy_. In addition it marks as “modified” all parent nodes to trace the changed branch.

The proxy tree generated is shown in the image below.

Finally, when the producer function terminates, Immer retraces the proxy tree by cloning the modified nodes and returning the references of the unmodified ones. At this point Immer  combines clones, freezes the whole objects and then revokes all proxies.

The Immer behavior can be more clear with a live example:

To perform the shallow clone, Immer uses an internal function called shallowCopy. That function, if the target is an object, creates a shallow copy with the Object.create(objPrototype, objDescriptor). Like Object.assign, read any _own_, get/set accessors, but unlike Object.assign, non-enumerable properties will be copied as well.

Immer can work without a Proxy object for ES5 version, but in this case you need to call enableES5() as soon as possible in an application. ES5 implementation works in the same way but does not use proxy and for this reason is a bit slower.

Auto freeze
Link to this section

Immer freezes the state recursively, for large data objects that won't be changed in the future this might be overkill, in that case it can be more efficient to shallowly pre-freeze data with the freeze utility (by using: freeze(data)). Immer freezes any data structure you created using ‘produce’, that means you will get an error when trying to modify a freezed property.

Since version 8.0 auto-freeze is enabled by default in production to have the same behavior in both dev and prod, and to avoid unexpected bugs slowing down performance when auto-freeze is off (for more details see this Github issue). Of course, if you notice slowdowns caused by massive mutation of large data structures, you can decide to opt-out Immer.

Immer performance
Link to this section

Immer is very performant even when mutating large data structures, in fact it takes only 145ms to update 10k objects in a set of 100k. However, for this case, an efficient handcrafted reducer is more performant (just 30ms) but is a border line example with a really big dataset.

Here's what Immer docs have to say:

Something that isn't reflected in the numbers above, but in reality, Immer is sometimes significantly faster than a hand written reducer. The reason for that is that Immer will detect "no-op" state changes, and return the original state if nothing actually changed. Cases are known where simply applying Immer solved critical performance issues.

NgRx-Immer
Link to this section

NgRx-Immer allows to write strongly typed, clear and concise reducers. Immer and NgRx-Immer give the freedom to choose when to use it, because it’s entirely opt-in. Furthermore Ngrx-Immer is a worthy choice because it will certainly be maintained in the future.

NgRx-immer API for NgRx-Store:
Link to this section

createImmerReducer is a wrapper around createReducer function. Immer will be used in the whole reducer, and the modified state must be returned.

immerOn is a wrapper around on function. You can decide whether to use on or immerOn inside the reducer and it is not need to return the modified state.

ImmerOn gives more flexibility and has less boilerplate, and for these reasons I recommend to use it. Here is the code snippet that demonstrates the advantages:

<>Copy
// <------ createImmerReducer ------> const todoReducer = createImmerReducer( { todos: [] }, on(completeTodo, (state, action) => { state.todos[action.index].completed = true; return state; }), ); // <------ immerOn ------> const todoReducer = createReducer( { todos: [] }, on(newTodo, (state, action) => { return { ...state, todos: [...state.todos, action.todo], }; }), immerOn(completeTodo, (state, action) => { state.todos[action.index].completed = true; }), );
NgRx-immer API for NgRx-Store

NgRx-immer API for NgRx-Component-Store
Link to this section

ImmerComponentStore is a wrapper around ComponentStore interface. This interface wraps only the updater and setState with Immer, but doesn’t wrap patchState.

Check out the following code example:

<>Copy
import { ImmerComponentStore } from 'ngrx-immer/component-store'; @Injectable() export class MoviesStore extends ImmerComponentStore<MoviesState> { constructor() { super({ movies: [] }); } readonly addMovie = this.updater((state, movie: Movie) => { state.movies.push(movie); }); }
NgRx-immer API for NgRx-Component-Store

Conclusion: Is it worth using Immer?
Link to this section

Immer is a phenomenal library and I personally use it everywhere even without NgRx. Auto-freeze in development and during unit tests is a great tool to find possible bugs. Beware that Immer with very large data structures can be less efficient than pure reducers. Also, in case of updates in very large arrays, it is advisable to look for the index in the original state and not in the draft.

I almost see no reason to not use Immer. It offers structural Sharing, type safe, performance, object freezing, more concise and easy to read code.

Comments (0)

Be the first to leave a comment

Share

About the author

author_image

Italian Developer, blogger, big fan of NgRx and CTO of OOPZ.

author_image

About the author

Nunzio Zappulla

Italian Developer, blogger, big fan of NgRx and CTO of OOPZ.

About the author

author_image

Italian Developer, blogger, big fan of NgRx and CTO of OOPZ.

Looking for a JS job?
NxAngularCli
NxAngularCli
NxAngularCli

Featured articles

JavaScriptpost
27 September 202130 min read
An in-depth perspective on webpack's bundling process

Webpack is a very powerful and interesting tool that can be considered a fundamental component in many of today's technologies that web developers use to build their applications. However, many people would argue it is quite a challenge to work with it, mostly due to its complexity.

JavaScriptpost
27 September 202130 min read
An in-depth perspective on webpack's bundling process

Webpack is a very powerful and interesting tool that can be considered a fundamental component in many of today's technologies that web developers use to build their applications. However, many people would argue it is quite a challenge to work with it, mostly due to its complexity.

Read more
JavaScriptpostAn in-depth perspective on webpack's bundling process

27 September 2021

30 min read

Webpack is a very powerful and interesting tool that can be considered a fundamental component in many of today's technologies that web developers use to build their applications. However, many people would argue it is quite a challenge to work with it, mostly due to its complexity.

Read more