How we use MobX to solve our frontend application state problems

picture of authorAhmad Faiyazon 15 Oct 2019

How we utilize MobX at Dataform to solve our frontend application state problems

Having a state management library on a React based single page application is quite useful, especially if the application is complex in nature, for example, if we want to share states between two react components which are neither siblings nor child. But even if you use a state management library, it might not solve the application state in a clean and expected way.

What library did we use before?

We initially used our in-house developed state management tool, which I will refer to as Goggle Store in this whole article. Goggle Store follows object oriented style, where you need to create state entity and state entities have a flat structure. And the store implementation was type-safe.

What problems did we face with Goggle Store?

  1. As an early stage startup, we couldn’t invest a lot of development time on this in house Goggle store. So we have little to no documentation for the store.
  2. Goggle store uses React’s “forceUpdate” method to re-render react components on state change, which made our React app rendering kinda inefficient. Also forceUpdate usage is discouraged on React’s documentation.
  3. We have to do “console.log” based debugging to check current state of the application with Goggle store.
  4. Not having control over mutating the state on Goggle store, means one can set values in any component by directly calling entity.set(x) which makes hard to keep track of where state is mutated. We had to search the whole code base to find out where set method is being called.
  5. Goggle Store doesn’t have caching mechanism for some state combination. For example, on our Dataform web application, you can switch git branches, so if you open some directories on Branch A, then switch to Branch B open some other directories, then move to Branch A again, we couldn’t show the directories you opened last time due to lack of scoped state caching mechanism.
  6. Goggle Store code structure doesn’t enforce state dependency, so one can add a state entity to the store and make it independent even though it is supposed to be dependent on other state(s). We found many bugs related to this issue, as the developer forgot to reset value on some state changes, which led to inconsistent information on the UI.

After having all those issues above, we finally decided to move from Goggle store to another store library, which should solve the above problems and make our life easier.

We chose MobX

We did some R&D with two state management libraries named Redux and MobX. With Redux, we couldn’t have an object oriented structure: it seems best practice for Redux is to have flat store structure. Another thing about Redux is that it requires lots of boilerplate code to work with React, which seems annoying. And last but not least, we couldn’t find a solution to our caching and state dependency problem with Redux.

As a result, we decided on using MobX for our application because of its derivation feature, such as computed values and reactions. Also with MobX we can follow object oriented paradigm and it requires less boilerplate code to work with React. We turned on enforceActions flag so that one can mutate state only inside an action. We have turned mobx-logger on so that one can see how state changes. But MobX didn’t solve our caching and state dependency enforcement issue. To solve those issues we have introduced a state dependency tree.

State Dependency Tree

We grouped our state entities in a store, and created a dependency tree. Our entity structure with Goggle Store (simplified) is like this:

We converted the state like a tree on MobX below:

So the code implementation looks like:

import {action, computed, observable, runInAction} from 'mobx';
import Loadable from './loadable';

export default class Loadable<T> {
  // our state entity class

  public static create<T>(val?: T) {
    return new Loadable<T>(val);
  }

  @observable private value: T;
  @observable private loading: boolean = false;

  constructor(val?: T) {
    this.set(val);
  }

  public isLoading() {
    return this.loading;
  }

  public val() {
    return this.value;
  }

  public set(value: T) {
    this.loading = false;
    this.value = value;
  }

  public setLoading(loading: boolean) {
    this.loading = loading;
  }
}

interface IProject {
  projectName: string;
  projectId: string;
}

export class RootStore {
  @observable public currentProjectId: string = null;
  @observable public projectsList = Loadable.create<IProject[]>();
  public readonly projectStoreMap = new Map<string, ProjectStore>();

  public projectStore(projectId: string) {
    if (!this.projectStoreMap.has(projectId)) {
      const project = this.projectsList
        .val()
        .find(project => project.projectId === projectId);
      if (!project) {
        throw new Error('Project not found');
      }
      this.projectStoreMap.set(projectId, new ProjectStore(project));
    }
    return this.projectStoreMap.get(projectId);
  }

  @computed public get currentProjectStore() {
    return this.projectStore(this.currentProjectId);
  }

  @action public setCurrentProjectId(projectId: string) {
    this.currentProjectId = projectId;
  }

  @action.bound
  public async fetchProjectsList() {
    this.projectsList.setLoading(true);
    const response = await ApiService.get().projectList({});
    runInAction('fetchProjectsListSuccess', () =>
      this.projectsList.set(response.projects)
    );
  }
}

interface IBranch {
  branchName: string;
}

class ProjectStore {
  public readonly currentProject: IProject;
  @observable public branchList = Loadable.create<IBranch[]>();
  @observable public currentBranchName: string = null;
  public readonly branchStoreMap = new Map<string, BranchStore>();

  constructor(project: IProject) {
    this.currentProject = project;
  }

  public branchStore(branchName: string) {
    if (!this.branchStoreMap.has(branchName)) {
      const branch = this.branchList
        .val()
        .find(branch => branch.branchName === branchName);
      if (!branch) {
        throw new Error('Branch not found');
      }
      this.branchStoreMap.set(branchName, new BranchStore(branch));
    }
    return this.branchStoreMap.get(branchName);
  }

  @computed public get currentBranchStore() {
    return this.branchStore(this.currentBranchName);
  }

  @action public setCurrentBranchName(branchName: string) {
    this.currentBranchName = branchName;
  }

  @action.bound
  public async fetchBranchList() {
    this.branchList.setLoading(true);
    const response = await ApiService.get().branchList({
      projectId: this.currentProject.projectId,
    });
    runInAction('fetchBranchListSuccess', () =>
      this.branchList.set(response.branches)
    );
  }
}

const rootStore = new RootStore();

We have utilized the computed value feature to add state dependency. So the developer doesn’t need to know which state entity they need to change. And as we have grouped entities together in a domain based store object, we can now cache the states for which we are using ES6 map, please take a look at line 46-57 for further understanding.

Conclusion

In software development world, no library is good at everything, which is also true for MobX. For example: its documentation, dev-tools are not rich like Redux but so far it is solving our problems. Many people don’t know about MobX as Redux is quite popular in react world. But I think, MobX can also be a great state management solution for many react developers.