Note: This article is not a mobx-state-tree usage guide. In fact, the whole article has nothing to do with MST (mobx-state-tree).

Foreword

As an official state model building library, MST provides many wonderful features such as time travel, hot reload, and redux-devtools support. But the problem with MST is that it is too opinioned(the official site had mentioned), and you must accept a set of values ​​(just like redux) before using them.

Let’s take a quick look at how to define a Model in MST:

import { types } from "mobx-state-tree" const Todo = types.model("Todo", {

title: types.string,

done: false

}).actions(self => ({

toggle() {

self.done = !self.done

}

})) const Store = types.model("Store", {

todos: types.array(Todo)

})

Honestly, when I saw this code for the first time, my heart refused, it was so subjective. Intuitively, we use MobX to define a model that should looks like this:

import { observable, action } from 'mobx'

class Todo {

title: string;

@observable done = false; @action

toggle() {

this.done = !this.done;

}

} class Store {

todos: Todo[]

}

Defining a model in a class-based way is obviously more intuitive and pure for developers, and the “subjective” approach of MST is somewhat counterintuitive, which is not friendly to the maintainability of the project (class-based approach can be understood as long as who know the most basic OOP). But correspondingly, the capabilities provided by MST such as time travel are really attractive. Is there a way to write MobX in the usual way and enjoy the same features in MST?

Compared to MobX’s multi-store and class-method-based action, the serialization unfriendly paradigm, the support of Redux for time travel/action replay is obviously much easier (but the corresponding application code is more cumbersome). But as long as we solve two problems, time travel/action replay supporting problem in MobX will be solved:

1. Collect all the stores of the app and activate them reactively, manually serializing them when they changed. Complete the store -> reactive store collection -> snapshot(json) procedure.

2. Identify the collected store instances and various mutations (actions) and map the relation. Complete the reverse process of snapshot(json) -> class-based store.

For these two issues, mmlpx gives the corresponding solution:

1. DI + reactive container + snapshot (collect stores and respond to store changes, generate serialized snapshot)

2. ts-plugin-mmlpx + hydrate (identifies the store and aciton, hydrate the serialized data into a stateful store instance)

Let’s take a look at how mmlpx gives these two solutions based on snapshot.

The basic capabilities that Snapshot needs

As mentioned above, in order to provide snapshot capabilities for the application state under MobX, we need to address the following issues:

Collect all the stores of app

MobX itself is unopinioned in the with the app organization. It does not restrict how the application organizes the state store, follows a single store (such as redux) or multi-store paradigm. However, since MobX itself is OOP, in practice we usually adopt the MVVM mode. The Code of Conduct defines our Domain Model and UI-Related Model (how to distinguish between the two types of models can be seen in MVVM-related articles or MobX official best practices, which are not repeated here). This leads us to follow the multi-store paradigm subconsciously when using MobX. So what if we want to manage all the stores in the application?

In the OOP worldview, to manage instances of all classes, we naturally need a centralized storage container, which is often easily referred to as an IOC Container. As the most common type of IOC implementation, DI (Dependency Injection) is a great alternative to the way you manually instantiated the MobX stores. With DI, the way we refer a store looks like this:

import { inject } from 'mmlpx'

import UserStore from './UserStore' class AppViewModel {

@inject() userStore: UserStore



loadUsers() {

this.userStore.loadUser()

}

}

After that, we can easily get all the store instances instantiated through dependency injection from the IOC container. This solves the problem of collecting all the stores in MobX.

More DI usage see here: mmlpx di system.

Respond to all store state changes

Once you have all the store instances, the next step is to listen for changes in the states.

If all the stores in the application have been instantiated after the application has been initialized, it is relatively easy to monitor the changes across the application. But usually in a DI system, the instantiation is lazy, which is only instantiated when a store is actually used, and then its state initialized. This means that from the moment we turn on the snapshot feature, the IOC container should be converted to reactive, allowing automatic binding listeners for the state defined in the newly added store.

Now, we can get all the currently collected stores by the current IOC Container through the onSnapshot method, then build a new container based on the MobX ObservableMap , load all the previous stores, finally do the data redefinition and recursive track the dependencies with reaction , so that we can respond to the container (Store add/remove) and store state changes. If the change triggers the reaction, we can manually serialize the current application state for the app snapshot.

Specific implementation can be seen here: mmlpx onSnapshot

Wake the app up from snapshot

Usually we take the snapshot data of the application and make it persistent to ensure that the application can be directly resumed to the state at exiting when next time it enters — or we need to implement a common redo/undo feature.

This is relatively easy to implement in the Redux system because it defined with plain object and it is serialize-friendly. But it does not mean that you can’t wake up an application from snapshot in a serialized-unfriendly MobX system.

To successfully resume from snapshot, we have to achieve these two conditions:

Add a unique identifier to each store

If we want the snapshot data after serialization to be successfully restored to its own store, we must give each store a unique identifier so that the IOC container can associate each layer of data with its original Store via this id.

Under the mmlpx scheme, we can mark global state and local state of the application with @Store and @ViewModel decorator, and give the corresponding model class an id:



class UserStore {} @Store ('UserStore')class UserStore {}

But manually naming the Store is stupid and error-prone, you have to make sure that your namespaces don’t overlap (which is exactly what redux does🙃).

Fortunately, ts-plugin-mmlpx saved us. We only need to define the Store like this:

@Store

class UserStore {}

After the plugin transpiled, it becomes:

@Store('UserStore.ts/UserStore')

class UserStore {}

The combination of fileName + className usually can ensure the uniqueness of the Store namespace. For more information on plugin usage, please check the ts-plugin-mmlpx project home page.

Hyration

Activate the reactive system of app from the serialized snapshot state, the reverse process from static to dynamic is very similar to hydration in the SSR. In fact, this is the most difficult step to implement Time Travelling in MobX. Unlike the Flux-inspired library, such as redux and vuex, the state in MobX is usually defined based on the class’s hyperemia model. After dehydrating and refilling the model, we must also ensure that those behaviors that cannot be serialized are defined still correctly bound to the model context. The rebinding behavior is not yet complete, we must make sure that the mobx definition of the data after deserialization is also consistent with the original. For example, I used a special modifier such as observable.ref , observable.shallow , and ObservableMap , we must keep the original ability after refilling. Especially for ObservableMap who can not be serialized, we have to find a way to let them resume to the original state behavior.

Fortunately, the cornerstone of our entire solution is the DI system, which gives us the possibility to “hands-on” when the caller requests to get dependencies. What all we have is to recognize whether the dependency is populated from the serialized data when the dependency getting, that means, the instance stored in the IOC container is not an instance of the original class type. At this time, the hydrate action is started, and then hydration returned. The activation process is also very simple, since we have the Store class type(Constructor) in the context of inject , we just need to re-initialize a new blank store instance and fill it with serialized data. Fortunately, MobX has only three data types, object, array, and map. We only need to do a simple treatment of different types to complete hydrate:

if (!(instance instanceof Host)) { const real: any = new Host(...args); // awake the reactive system of the model

Object.keys(instance).forEach((key: string) => {

if (real[key] instanceof ObservableMap) {

const { name, enhancer } = real[key];

runInAction(() => real[key] = new ObservableMap((instance as any)[key], enhancer, name));

} else {

runInAction(() => real[key] = (instance as any)[key]);

}

}); return real as T;

}

Here is the source code of hydrate.

Scenarios

Compared to MST’s snapshot capabilities (MST can only take snapshots of a certain store, not the entire application), the mmlpx-based approach makes it easier to implement Snapshot-derived features:

Time Travelling

The Time Travelling feature has two application scenarios in actual development, one is redo/undo, and the other is action replay function provided by redux-devtools.

After using mmlpx, redo/undo implementation is very easy in MobX. Actually you just need onSnapshot and applySnapshot the two apis. The beginning gif picture of article was the redo/undo demo and you can check the mmlpx project home for details.

Functions like redux-devtools are a bit more complex to implement (Actually it is simple), because we want to replay each action, a unique identifier for each action we should provide. The goal in redux is achieved by manually writing action_types with different namespaces. This is too cumbersome. Fortunately, we have ts-plugins-mmlpx which can help us automatically name the action (a same as automatically giving the store a name). After solving this, we only need to record each action at the same time when onSnapshot working and then we can easily use the function of redux-devtool in MobX.

SSR

We know that when React or Vue is doing SSR, the prefetched data is passed to the client by mounting global variables on the window, but usually the official example is based on Redux or Vuex, there are some problems need to be solved if we wanna use MobX instead. Now with the help of mmlpx, all we need is to use the prefetch data to apply snapshots on the client before the application is started:

import { applySnapshot } from 'mmlpx' if (window.__PRELOADED_STATE__) {

applySnapshot(window.__PRELOADED_STATE__)

}

App crash monitoring

This feature could be provided by every library who can snapshot app states and resume them from. That is to say, when the application crash is detected, the shutter is pressed and then upload the snapshot data to the cloud, then the cloud platform can restore the scene through the snapshot data. If the snapshot data we upload also includes the user’s previous operation stack, it is possible to replay the user operation on the monitoring platform.

In the End

As a believer in the “multi-store” paradigm, MobX replaced the position of redux in the field of front-end state management in my mind. However, due to the lack of centralized management of the store under the MobX multi-store architecture, it has been lacking in the development experience of a series of functions such as time travelling. Now with the help of mmlpx, MobX can also turn on Time Travelling, and the last advantage of Redux in my mind is gone.