How the MobX library helps to manage the state of web applications. Lecture in Yandex
The lack of dependencies in the web application leads to errors in the interface, the excess reduces performance. Head of Interface Development at Yandex Azat razetdinov shows how the MobX library helps to track the minimum set of changes and maintain the consistency of the application state, and also introduces the mobx-state-tree tool, which allows you to combine all the best of MobX and Redux.
The fact that we are trying to work with immutable data is not necessary. The Immutable state of our application is another view, another view, another view. You can use a living model, just to get its flat projection every time at any time.
')
- My name is Azat Razetdinov, I represent Yandex personal services: Mail, Disk, Calendar, Passport, account management. I would like to tell you about managing the state of a web application without pain. What is the status of the application? This is a central concept in the architecture of the entire web application. It stores everything that the other components depend on. For example, the most obvious is the display, the representation of the state of the application in the form of a house-tree, which you see in the browser.
This is the largest part, but many forget that there are still other parts. The current URL in the address bar also depends on the state of the application. It is very important that a person can copy the URL on any page of your application, send it to a friend, and the same thing is opened there. The current URL should always correspond to the current state and reflect it.
Most likely, you have the synchronization of the application state to the server. It is important to always be sure that everything that is changing on your client somehow ends up at the server.
There are cases when we want to store some local changes in the repository, in order to get them from there later. Store right in the browser. Here, too, often requires the synchronization of the state of the application with the local storage. In fact, there are quite a few parts that depend on the state of the application. What is the problem here?
As a rule, the state of an application is a tree, where there is a lot of data, there are some lists, objects, hashes, primitive data. Large hierarchical structure. The problem is not that it is hierarchical, but that it is alive, it is constantly changing. It changes in one place, then in another. What problem do we want to solve?
If you have been in the frontend for a long time, then you are probably familiar with such a pattern as a manual subscription to changes.
We take the current state, use it for the initial display of the component, then subscribe to the changes and react to them.
If you recall the old frameworks, it looked like this in pseudocode. First, we use the current state, subscribe to changes, and with each change, we perform some actions. Either we change the house-elements to a point, as it was fashionable before, or we restart the whole render, as it is fashionable now.
This approach has two problems.
If you subscribe to change any node in the tree, then most likely you are doing too many actions. Most likely, your current component does not depend on the entire state of your application tree and you have too many unnecessary operations. So in this place they usually start to do some kind of optimization, they try to select only those fields of the application state, on which your particular component or some action depends.
But this approach also has a problem.
Since you do it manually, in a project that lives for a while, sooner or later, there comes a state where you have forgotten something, refactored and did not add dependence on some field that may affect the display of your component. . What happened here? A comrade updated his avatar, but it was not updated everywhere. It turned out that the large avatar changed, and the small avatars in tweets did not subscribe to the user's avatar changes and did not receive this change, did not update themselves. This is the biggest disadvantage that is in the manual subscription.
This is where MobX comes to the rescue. It implements the subscription exactly on the application status fields that you use.
To show it, you need to explain how it is arranged from the inside.
I will use decorators. Do not worry: everything that is written with the help of decorators, you can write using the usual functions of wrappers. Decorators are only for clarity and conciseness. Let's declare such a class - Person, man. And we will declare three fields, and mark them as an observable decorator. Name, surname and nickname.
When we talk about MobX, it is very useful to draw an analogy with Excel. Observable fields are simply raw data in cells.
They allow the rest of the concepts to follow the changes themselves.
Computed is similar to observable in that it can also notify those who subscribe to it about their change, but it does not store values ​​within itself, but calculates them based on other observable fields. In this case, we simply concate the first and last name separated by a space. If we draw an analogy with Excel, this is a cell with a formula. It seems so far simple.
This is not an action that you probably know from Redux, but it is very similar. In terms of MobX, action is simply a function. Here it is a method, but it is optional. Action is not required to be a class method, it can be anywhere in the application, as long as it is marked by the action decorator. Inside this function, you can change the observable fields that you have previously marked.
While everything is clear, the method sets nickName.
Now the magic begins.
The most important concept of MobX is reactions.
They are similar to computed, they also use some observable or computed fields within themselves, but they do not return any value. Instead, they give a side effect.
The most important thing: the reactions are triggered, executed or restarted each time the source data changes. In this case, not any, but only those that depend on each specific reaction.
The simplest reaction is the autorun function from the MobX library. Let's write a simple autorun, to which a function is passed, simply outputting some expression to the console. There is no good analogy with Excel: the reactions are not obliged to return any value, they rather give some side effect. Approximately we can say that this is another formula in the cell.
Autorun, as soon as we call it, immediately launches our function for the first time, which we passed in the argument.
When performing this function, it accesses observable fields, in this case, the first thing to do with nickName. This is where MobX magic works: in fact, when we declared observable, getter for this field was declared instead of a regular field.
When we apply, the observable field nickName sets an increment in itself: aha, I have a new listener for a function that is wrapped in autorun.
When something changes with me, I need to notify this listener about this change. NickName is empty, so the call to Person fullName goes on. We are subscribing to change this field. FullName is a computed field; it is a getter that internally refers to the fields firstName and lastName. This completes the execution of the function, and at this moment MobX knows that the function we passed to autorun depends on four fields: nickName, fullName, firstName, lastName. The dependency tree looks like this. Any change to observable fields in the first column will restart autorun execution. Suppose we decided to give our little man the nickname Vasek. This method, which is an action, performs an assignment operation within itself.
When you call this operation, the setter is triggered, and it passes through the list of subscribers inside and notifies everyone: I have changed, you need to somehow validate your state, be over-fulfilled or redirected. Autorun receives a notification that something has changed, you need to restart again. Runs the function execution, refers to the nickName field.
This time it is no longer empty. This function is terminated. See how the list of observed fields has changed. Since we only accessed the nickName field, it remains in our list of dependencies. All the other three fields from the list of dependencies also fly out. If you look at the tree, it now looks like this. Until nickName changes, autorun will generally ignore any changes to the firstName and lastName fields, because the code is designed in such a way that as long as nickName is not empty, it never even goes to the fullName field.
It is very important to understand that the reactions with each execution re-assemble a list of their dependencies. The list of fields on which your component depends or your side reaction is not collected statically, not in the code you write it. An array of fields that I need to track - it is collected dynamically based on the analysis of the code that is being executed. The minimum set of subscriptions can be obtained only when they are collected in runtime.
Autorun is not the only example of a reaction. There is an observer reaction. This is a helper for React.
If our example is rewritten as a React component, it will look something like this. We use the observer decorator. Let me remind you: you can use regular wrappers here. Inside the render method, we first refer to nickName. If it is empty, then to fullName. Exactly the same logic. The only thing is that when using observer we do not perform the autorun function, but instead of it, with any change of the fields we are subscribed to, it starts forwarding your component.
Automatic subscription of components plus observer allows you to drastically minimize the number of redraws of React components. There is often observable code when there is some kind of flag that we check at the very beginning of the render method. If it does not execute, we simply return null. React magic helps a lot. As long as our changes are false, changes to any fields that are used below, where a lot of code is written, will ignore observer. But as soon as the flag lights up, during the next re-tender, he will execute the next code and subscribe to changes in the fields that are used there.
If React saves us home operations, then MobX saves us virtual home operations. The fewer redraws even in a virtual house, the faster our application.
I'll tell you about another optimization that is built into MobX, computed caching. Here our fullName is simple, but in general they are quite complex: some filters, reduce, complex calculations. The question arises: if every time we turn to this getter, will we not have too much of performing all these operations, we will perform the same operation every time? Why not put in the cache? Until the data that computed uses has changed, the computed first performs its calculations, puts the value in the cache, and every time someone calls it, it returns it immediately from the cache. But if we set the nickName field, and autorun unsubscribes from fullName, at this moment fullName realizes that it no longer has any subscribers, throws out the cache, which is then collected through the garbage collector and works just like a regular getter.
Caching always depends on having subscribers, which can always be more than one.
A small example of how to work with asynchronous data with this approach.
You can manually start the load method, run the isLoading True or False flag, but MobX has a helper called fromPromise. We declare a certain field, wrap the asynchronous operation in the helper fromPromise, and in this field there are two sub fields - state and value.
In the React component, you can first verify that state pending. Then we show some loading. If fullfilled, then we turn to the value field and draw our component further.
Total advantages MobX. Already hear a question from the audience. I call this little man Reduxman, this is the man who wrote a lot of code on Redux. What question is he asking?
What about netability? What is this? Can you change the methods of the model's fields directly? Well, a fig yourself.
What about time travel? I don't need models with methods, but simple plain JavaScript objects so that you can use them to do undo, redo, and other tasty things.
How is my favorite devtools, to which I was already used, so that you can do a replay of the actions that the user did?
I'll tell you a little about Redux. The main changes that he made in the minds of developers. He moved away from OOP towards functional programming. Immutable structures were used instead of models. Instead of methods now there are actions and reduers. Instead of normal links, links between models now have normalization and selectors.
And it's very cool, I also love Redux affectionately, but one thing confuses me: a lot of boilerplate, a lot of things have to be written by hand. When I need to add some action, I have an action, reducer, often I need a selector. And there is a feeling that I am doing a monkey job.
When I began to think how Redux differs from MobX, I had this analogy.
Did everyone like this cartoon? And what is the difference between cartoons that the younger generation watches? They are like that.
Do you know what the difference is? "Tom and Jerry" drew in this way, took the frames and each drew separately. Nothing like? Immutable store in a redux application. Every time there is some kind of footprint that we construct with our hands, use for this the library immutable or Object.assign or spread operator. Each time we finish drawing the current state of the application with our hands. If you need to roll back, we take back and roll back. This is all cool, only a lot of code is obtained. I do not like to write code, I like to delete it. The code is evil. The fastest code is one that is not executed.
And new cartoons draw like this.
They draw a three-dimensional model, programmatically rotate it, take a frame, turn it the other way, take a frame. Manage a live model, and then just take its projection on the screen.
The fact that we are trying to work with immutable data is not necessary. The Immutable state of our application is another view, another view, another view. You can use a living model, just to get its flat projection every time at any time.
Let's show how to do it. MobX authors have written such a separate piece. This is already a more opinionated approach, which dictates how to write an application, but in return gives many goodies.
Let's write a small store, declare a class Todo, for this we use the helper types, which has a method model. While it is empty. Add a title.
Here we declare that it is a string.
Add an optional boolean field isCompleted. By the way, here it is possible to write it in short. If you assign a primitive, then mobx-state-tree understands that this is an optional primitive field with a default value. Add a reference. This means that in the folder there will be an id of some other object, but when creating a mobx-state-tree model on this id, it will get this object from a certain store and put it in this field. I will show an example later. In order for all the magic to work, we need to declare the class Folder, which must have an id with the types.identifier type. This is just to link references to store objects by identifier. Let's declare the main root TodoStore, which will have two arrays: todos and folders. Here you can see how types.array is used, pass a class as an argument, and MobX understands that this is an array of the instance of this class. If we declare a getter, it automatically becomes computed from the terminology of MobX, as we have seen before. Here I have a completedTodos getter, which simply returns a list of all todo executed. It is cached, and as long as there is at least one subscriber, it always returns the cached value. Do not be afraid to write so, to write complex expressions, it will all be cached. This is how actions are created. The first object in the declaration is properties and computed, in the second object actions are listed. Here you don’t need to declare them, the mobx-state-tree by default considers that everything that you pass to the second object is action. Let's try to create a store. We have data, let's say they came from the server, see, they are in normalized form, we have 1 in the folder, and the list of folders has an object with the identifier 1. We create, we use.
The first line - everything is fine, I use the title field of the todo object.
The second line already has magic: since the folder is declared as reference, when creating a model, MobX automatically, first of all, put the folder in the folders array, and in the todo models, by reference, by identifier, added a link to this object. Where in Redux we would write a selector, works out of the box here. You can safely refer to the nested fields of your links, your references. And it works, it is very convenient to write in components without any selectors and other map state to props.
We collected some kind of 3D model. Let's try to run it. Camera, motor. To begin, let's try to get back the data that we put in the model. For this there is a helper getSnapshot. We transfer there model, we receive snapshot in the form of a usual JS object, as all redaxmen like. Received and received, but my model is constantly changing, how can I subscribe to changes? It's very simple: there is an onSnapshot helper that allows you to subscribe to change any field in the model, while it always passes a new snapshot as a parameter, which it generates, but not just like that, otherwise it would be foolish to generate a new object each time. It, like React, uses immutable.
If some parts have changed, he reuses them, starts the mechanism of structural sharing.
For changed creates new objects. How to make time travel? We walk through the history and want to apply some snapshot to the model. There is a applySnapshot helper, pass the model, pass and snapshot. He compares what you conveyed, and what is now in the model, takes the differential and updates only those parts that have changed.
However, he reuses models if their identifiers match. If there are some folders with id = 1 in the model, folders with id = 1 are also transferred to the snapshot. It does not try to redo it, but simply updates the data of the folder itself if they have changed.
The instance inside the model is not overridden if you set the identifiers correctly.
Perhaps the most vivid illustration of how live models and snapshots work.
There is a live model, and we can take a snapshot from it at any time.
Finally, a bonus, especially for redaksmenov. There is an adapter to work with Redux. , Redux style, store mobx-state-tree store reduxStore asReduxStore. ReduxDevtools, connectReduxDevtools, , store mobx-state-tree, . - immutable-. -, , , . - , .