📜 ⬆️ ⬇️

Immer: a new approach to immunity in JavaScript

Immunity data structures that implement the method of sharing unchanged pieces of information (structural sharing), look like an excellent technology to store the state of the application. Especially in combination with event-based architecture. However, you have to pay for everything. In a language like JavaScript, where the ability to provide immunity is not standard, creating a new state from the previous one is a boring, patterned task. In order to understand the scale of the problem, and the forces thrown at its solution, take a look at this page , where there is a list of 67 packages designed to simplify working with the immutable data structures in Redux.



Unfortunately, all these libraries do not solve the main problem: the lack of support for immunity with the language. For example, while update-in is a beautiful design of the ClojureScript language, any similar ideas implemented in JavaScript will mainly rely on inconvenient string paths. This approach is error prone, it complicates type checking and requires learning a particular API.
')
How to solve the problem of immunity in javascript? Perhaps we should stop fighting the language, using its capabilities instead. This approach will allow not to lose the convenience and simplicity, which give the standard data structures. As a matter of fact, the immer library, which we will talk about today, aims to use the standard JS tools when working with immunityable states.

Producers


The basis for the practical use of immer is the creation of producers (producers). Here’s what a very simple producer looks like:

 import produce from "immer" const nextState = produce(currentState, draft => { //   }) console.log(nextState === currentState) // true 

The only task that this empty producer solves is to return the original state.

The produce function takes two arguments. This is currentState , current state, and producer function. The current state is the starting position, and the producer expresses the changes that need to be made to the current state.

The producer function takes one argument, draft , which is something like a draft of a future state and is a proxy object for the current state passed. Changes made to the draft will be recorded and used to create a new state. The current state, currentState , during the execution of this process will not be subject to any changes.

In the above example, since immer uses common data structures and the producer does not modify anything, the next state will be the same as the input to the produce function.

Now let's look at what happens if we modify the draft object in the producer. Note that the producer function returns nothing, the only thing that plays a role is the changes it performs.

 import produce from "immer" const todos = [ /*  2  todo */ ] const nextTodos = produce(todos, draft => {   draft.push({ text: "learn immer", done: true })   draft[1].done = true }) //     console.log(todos.length)        // 2 console.log(todos[1].done)       // false //    ,   draft console.log(nextTodos.length)    // 3 console.log(nextTodos[1].done)   // true //   console.log(todos === nextTodos)       // false console.log(todos[0] === nextTodos[0]) // true console.log(todos[1] === nextTodos[1]) // false 

Here you can see an example of a real producer. All changes to the draft are reflected in the new state, which uses immutable elements along with the previous state.

Here you can watch the produce function in action. We have created a new state tree that contains one additional todo element. In addition, the second element has been modified. These are changes made to the draft object and reflected in the resulting state.

However, this is not all. The last expression in the listing demonstrates in practice that parts of the state that were changed in the draft turned out to be in new objects. However, the unchanged portions of the new and previous states are shared. In this case, this is the first todo element.

Reducers and Producers


Now that we have learned the basics of creating new states with the help of producers, we will use this knowledge to create a typical Redux reducer. The following example is based on the official example of a shopping cart; it loads state information about (possibly) new products. Product information comes in the form of an array transformed using reduce , and then stored in a collection using their ID as keys. Below is an abbreviated version of the code, the full version can be seen here .

 const byId = (state, action) => { switch (action.type) {   case RECEIVE_PRODUCTS:     return {       ...state,       ...action.products.reduce((obj, product) => {         obj[product.id] = product         return obj       }, {})     }   default:          return state } } 

Here, in this quite ordinary Redux reducer, you need, first, to construct a new state object in which the base state is saved and to which a collection of new goods is added. In this simple case, this is not so bad, but this process needs to be repeated for each action, and at each level where you need to change something. Secondly, it is required to ensure the return of the existing state, if the reducer did not make any changes to the state.

If you apply immer in the described situation, then the only solution that we need to take is what changes need to be made to the current state. Additional efforts to create a new state will not be required. As a result, if you use the produce function in the reducer, we come to the following code:

 const byId = (state, action) => produce(state, draft => {   switch (action.type) {     case RECEIVE_PRODUCTS:       action.products.forEach(product => {         draft[product.id] = product       })       break   } }) 

This is a simplification of the viewer using immer features. Pay attention to how much easier it is to understand now what role RECEIVE_PRODUCTS plays. Code that only adds information noise has been removed. In addition, please note that we do not process the default action here. If the draft object is not changed, this is equivalent to returning the base state. Both the original reducer and the new one perform exactly the same actions.

About string identifiers that immer does not use


The idea of ​​producing the next immunity state by modifying a temporary “draft” object is not new. For example, ImmutableJS has a similar mechanism - withMutations . Immer's significant advantage, however, is that in order to use this library, it is not necessary to study (or load) a whole new library of data structures. Immer works with regular JavaScript objects and arrays.

Immer strengths do not end there. To reduce the template code, ImmutableJS and many other libraries allow expressing deep updates (and many other operations) using special methods. However, ordinary string identifiers are used here, which makes it impossible for type-checking systems to work. This approach is a source of error. In the following listing, for example, the list type cannot be displayed when using ImmutableJS. Other libraries extend similar mechanisms, allowing them to perform more complex commands. The price of all these possibilities is the creation of a special mini-language in the existing programming language. Here are some examples of code that uses the features of ImmutableJS and immer.

 // ImmutableJS const newMap = map.updateIn(['inMap', 'inList'], list => list.push(4)) // Immer draft.inMap.inList.push(4) 

It shows how, while working with immer, we do not lose information about types during deep updates. As you can see, immer does not suffer from problems with types. This library works with embedded JavaScript data structures, data modification is performed using standard mechanisms. All this is perfectly perceived by any type checking system.

Automatic freezing of objects


Another great feature of immer is that this library automatically freezes any data structures created using the produce function. (In development mode). As a result, the data becomes truly immobile. Michael Tiller writes about this in his tweet . While freezing the entire state can be quite a resource-intensive procedure, the fact that immer can freeze the changed parts makes this library very efficient. And, if the entire state is obtained using the produce function, the final result will be that the entire state will always be frozen. This means that you will get an exception when you try to modify it in any way.

Currying


So, here is the last immer opportunity we will look at. So far, we have always called the produce function with two arguments — baseState and a baseState function. However, in some cases it may be convenient to use the mechanism of partial application of the function. With this approach, you can call the function produce , passing it only a function-producer. This will create a new function that the producer will execute when the state is passed to it. Such a function, in addition, takes an arbitrary number of additional arguments and passes them to the producer.

The most important thing here is that currying makes it possible to further reduce the template code of the reducers:

 const byId = produce((draft, action) => { switch (action.type) {   case RECEIVE_PRODUCTS:     action.products.forEach(product => {       draft[product.id] = product     })     break } }) 

Shown here is a curried producer. To better understand this code, take a look at one of the previous examples.

In general, here we have considered all the main features of immer. This is enough to get started with this library. And now we will talk about the internal mechanisms of this library.

Internal immer device


At the heart of immer are two concepts. The first is copying while recording . The second is proxy objects. We illustrate this.


Current state, proxy object and modified proxy object

A green tree is a tree of the original state. You may notice that some circles in the green tree have blue frames. They are called proxy objects. At the very beginning, when a producer starts work, there is only one such proxy. This is the draft object that is passed to the function. When you read any non-primitive value from this first proxy, it, in turn, creates a proxy for that value. This means that we end up with a proxy tree, which is something like a “shadow copy” of the base state. These are the parts of the state to which the calls were recorded in the producer.


Internal decision-making mechanisms in the proxy. Requests are sent either to the base tree or to the cloned base tree node

Now, as soon as you try to change something in the proxy (directly, or through any API), it immediately creates a small copy of the node in the source tree to which it belongs, and sets the modified flag. From this point on, any following read and write operations directed to this proxy will lead not to the source tree, but to its copy. In addition, parent objects that have not yet been modified will be marked as modified .

When the producer finally finishes the job, he will simply bypass the proxy tree, and if the proxy is modified, take a copy. Or, if the proxy is not modified, it will simply return the original node. This process leads to the appearance of a state tree, which has common parts with the previous state.

Immer without proxy objects


Proxy objects are available in all modern browsers. However, they are still not everywhere. The most notable exceptions are Microsoft Internet Explorer and React Native for Android. To work in such environments, immer has an ES5 implementation of its mechanisms. From a practical point of view, this is the same, but it works a little slower. You can apply these mechanisms using the import produce from "immer/es5" .

Performance


How does immer affect the performance of projects created with it? As the benchmarks showed, immer works about as fast as ImmutableJS, and twice as slow as an efficient, hand-made reducer. This is perfectly acceptable. The implementation on ES5, however, is much slower, as a result, it is quite reasonable to stop using immer in heavily loaded reducers on the platforms for which its ES5 version is intended. Fortunately, if you use immer in your project, this does not mean that you should use this library in all reducers, and you can decide which specific reducers and actions need immer features.


Immer performance testing

Perhaps, if we are talking about performance, it is best to first pay attention to the convenience of development, and optimize the speed of the executable code should be only if the need for such optimization is proved by the appropriate measurements.

Results


Work on immer was started as a small experiment with proxy objects. The resulting library, in the first week after the appearance, gathered over a thousand stars on GitHub. Immer, in dealing with immobility data in JavaScript, has some unique features. They probably liked the developer community. Perhaps it is also the fact that immer does not struggle with the language, but uses its standard features. Among the strengths of immer are the following:


Experiment with immer. It is possible that this library will be useful to you.

Dear readers! Do you plan to use immer in your projects?

Source: https://habr.com/ru/post/349492/


All Articles