The original link

Note: This article is notmobx-state-treeIn fact, the whole article has nothing to do with MST(Mobx-state -tree).

preface

Those of you who know mobx-State-Tree should know that MST, the official mobx state model building library, offers many useful features such as time travel, Hot Reload, and Redux-DevTools support. The problem with MST is that they are overly opinioned, and (as with Redux) one must accept their whole set of values before using them.

Let’s take a quick look at how MST defines a Model:

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)
})
Copy the code

To be honest, the first time I saw this code I said no, it was too subjective, and most importantly, it was too counterintuitive. Intuitively we use MobX to define a model that should look like this:

import { observable, action } from 'mobx'
class Todo {
    title: string;
	@observable	done = false;

	@action
	toggle() {
    	this.done = !this.done; }}class Store {
    todos: Todo[]
}
Copy the code

The class-based approach to defining a Model is clearly more intuitive and purest for developers, whereas the “subjective” approach of MST is somewhat counterintuitive and not very maintainable for the project (a class-based approach can be understood by anyone who knows the most basic OOP). But correspondingly, the capabilities that MST offers such as time travel are fascinating, so is there a way to comfortably write MobX in a regular way and still enjoy the same features as MST?

Compared to the serialization-unfriendly paradigm of MobX’s multi-store and class-method-based actions, Features such as Time Travel/Action Replay are obviously much easier to support with Redux (but the corresponding application code is also much more tedious). However, MobX’s Time Travel/Action Replay support was solved once we solved two issues:

  1. Collect and activate all stores of the app, and manually serialize (snapshot) when changes are made. Complete the store -> Reactive Store Collection -> Snapshot (JSON) process.
  2. The collected Store instances and all types of mutation(action) are identified and mapped. Complete the reverse process of snapshot(JSON) -> Class-based Store.

For these two problems, MMLPX provides corresponding solutions:

  1. DI + reactive Container + snapshot (collect store and respond to store changes, generate serialized snapshot)
  2. T-plugin-mmlpx + hydrate ()

Let’s look at how MMLPX provides these two solutions based on Snapshot.

Basic capabilities required by Snapshot

As mentioned above, there are several issues that need to be addressed in order to provide snapshot capability for app state under MobX:

Collect all stores for your app

MobX itself is a weak claim in terms of application organization and does not limit how an application can organize state stores or follow a single store(such as Redux) or multi-store paradigm, but because MobX itself is OOP oriented, In practice, we usually define our Domain Model and UI-related Model by using the MVVM pattern code of conduct (see the MVVM Related article or MobX official best practices for details). This leads us to follow the multi-store paradigm by default when using MobX. What if we want to manage all the stores of an app?

In the OOP world view, to manage all instances of a class, we naturally need a central Container, which is often associated with the IOC Container. DI(dependency injection), the most common IOC implementation, is a good alternative to manually instantiating the MobX Store. With DI, the way we reference a store looks like this:

import { inject } from 'mmlpx'
import UserStore from './UserStore'

class AppViewModel {
    @inject() userStore: UserStore
    
    loadUsers() {
        this.userStore.loadUser()
    }
}
Copy the code

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

See MMLPX DI system here for more DI usage

Responds to all store status changes

Once you have all the store instances, the next step is how to listen for changes in the state defined in those stores.

If all stores in the app are instantiated after the app is initialized, it is relatively easy to listen for changes in the entire app. But typically in a DI system, the instantiation is lazy, meaning that the state of a Store is initialized only when it is actually being used. This means that as soon as we activate the snapshot function, the IOC container should be converted to reactive, so that the newly managed store and the state defined in the store are automatically bound and listened to.

At this time, we can obtain the current IOC Container in onSnapshot, dump all the currently collected stores, and then build a new Container based on MobX ObservableMap. Load all the previous stores, and then recursively iterate over the data defined in the store. Use reaction to do track dependencies, This allows us to respond to changes in the container itself (Store add/destroy) and the state of the Store. If a change triggers reaction, we can manually serialize the current application state to get a snapshot of the current application.

The specific implementation can be seen here: MMLPX onSnapshot

Wake up the application from Snapshot

Usually we take a snapshot of the application and persist it to ensure that the next time we enter the application, the application will be restored directly to the state we left it in — or we implement a common redo/undo feature.

This is relatively easy to do in the Redux system because the state itself is plain Object and serial-friendly during the definition phase. This does not mean that it is impossible to wake up an application from Snapshot in a serial-unfriendly MobX system.

In order to resume from snapshot successfully, we must meet these two conditions:

Assign a unique identifier to each Store

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

In the MMLPX scenario, we can use @Store and @viewModel decorators to mark the global state and local state of the application and give the corresponding model class an ID:

@Store('UserStore')
class UserStore {}
Copy the code

Obviously, naming stores by hand is silly and error-prone, and you have to make sure that the respective namespaces don’t overlap (yes, that’s what Redux does).

The good news is that ts-plugin-mmlpx will automate this for you. When we define a Store, we just need to write:

@Store
class UserStore {}
Copy the code

After the plug-in transformation, it becomes:

@Store('UserStore.ts/UserStore')
class UserStore {}
Copy the code

The combination of fileName + className usually ensures the uniqueness of the Store namespace. For more information on the use of plugins, please visit the ts-Plugin-MMLpx project home page.

Hyration

The reverse process of activating the reactive system of the application from the serialized snapshot state and recovering from static state to dynamic state is very similar to hydration in SSR. In fact, this is the most difficult step in implementing Time Travelling in MobX. Unlike flux-inspired libraries such as Redux and Vuex, MobX states are usually defined based on the class congestion model. After dehydrating and reflooding the model, You must also ensure that action methods that cannot be serialized are still properly bound to the model context. Rebinding is not the only thing, we also need to make sure that the mobX definition of the data is the same after deserialization. For example, when I used data with special behavior such as Observable. ref, Observable. shallow, and ObservableMap to retain their original ability after reflooding, In particular, for non-object Array data such as ObservableMap that cannot be serialized directly, we need to find a way to reactivate them.

Fortunately, the cornerstone of our entire solution is the DI system, which gives us the possibility of “tampering” when callers request dependencies. We just need to determine if the dependency is filled with serialized data when it gets, that is, the Store instance stored in the IOC container is not an instance of the original type, then open the hydrate action and return the caller to the watered hydration object. The activation process is also simple. Since we inject a store context (Constructor), we simply reinitialize a new empty store instance and populate it with serialized data. Fortunately, MobX only has three data types, Object, Array, and Map, so we simply need to do some work with the different types to complete the hydrate:

if(! (instanceinstanceof Host)) {

    const real: any = newHost(... 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((a)= > real[key] = new ObservableMap((instance as any)[key], enhancer, name));
        } else {
            runInAction((a)= > real[key] = (instance as any)[key]); }});return real as T;
}
Copy the code

The complete code for this hydrate can be seen here: hyrate

Application scenarios

Compared to the Snapshot capability of MST (MST can only take a Snapshot of a Store, but not the entire application), MMLPX based solution makes it easier to implement the function derived from Snapshot:

Time Travelling

There are two practical applications for Time Travelling in development, either redo/undo or replay, provided by applications such as Redux-DevTools.

With MMLPX, redo/undo is easy to implement with MobX. Instead of the code (onSnapshot and applySnapshot), you can just post the renderings. For details, see the MMLPX project homepage

Something like Redux-DevTools is a bit more complicated (and actually easy) to implement, because we can replay every action only if each action has a unique identifier. The redux approach is to manually write action_types with different namespaces, which is too cumbersome (see Redux’s data flow management architecture for fatal defects and how to improve it in the future). . The good news is that we have ts-plugin-mmlpx that automatically names our actions (in the same way as automatically naming stores). With this out of the way, we can use the redux-devTool functionality in mobx by simply logging each action in onSnapshot.

SSR

We know that React or Vue do SSR by mounting global variables to the client, but the official examples are usually based on Redux or Vuex. MobX still has a few things to work out before it can be client-activated. Now, with the help of MMLPX, we can activate client state based on MobX by simply applying the snapshot on the client before the application starts with the prefetched data passed:

import { applySnapshot } from 'mmlpx'

if (window.__PRELOADED_STATE__) {
    applySnapshot(window.__PRELOADED_STATE__)
}
Copy the code

Application Crash Monitoring

This can be achieved by using a state management library with the ability to take a complete snapshot of the application at any given time and activate state relationships from the snapshot data. That is, when the application crash occurs, press the shutter to upload the snapshot data to the cloud platform, and then restore the snapshot data on the cloud platform. If the snapshot data we upload also includes the stack of the user’s previous actions, we can replay the user’s actions on the monitoring platform.

The last

As a believer in the “multi-store” paradigm, MobX immediately replaced Redux in the front end of state management in my mind. However, due to the lack of centralized store management in the previous MobX multi-store structure, the development experience of a series of functions such as time Travelling has been lacking. Now that MobX can also enable Time Travelling with the help of MMLPX, Redux’s last advantage in my mind is gone.