DEV Community

Cover image for MobX State Tree (MST) - State Management
kpiteng
kpiteng

Posted on • Edited on

MobX State Tree (MST) - State Management

Hello Developers, Everyone uses various State Management library on their Application, many of us already use Redux, Redux Saga, Redux Rematch. Today we will explore MobX which is the most popular Redux alternative. MobX State Tree (MST) is the powerful state management library which you can use from small to enterprise grade applications and it’s very simple to plug & play. I will take you from core concept to component level integration, So let’s continue,

What will we cover?

  • What is MobX-State-Tree?
  • Why should I use MobX-State-Tree?
  • MobX-State-Tree Installation
  • Getting Started - MobX Entity
  • Creating Model
  • Creating Model Instance
  • Meeting Types
  • Modifying Data
  • Snapshot
  • Snapshot to Model
  • Getting to the UI
  • Improving Render Performance
  • Computed Properties
  • References

What is MobX-State-Tree?

MobX-State-Tree (MST) is a reactive state management library. It is a container system built on MobX.

MobX - State Management Engine and MobX-State-Tree gives you a structure which has type + state to store your data. MST is most preferable from Small to Enterprise grade application where code & functionality is going to scale periodically. Compare to Redux MST offer powerful performance and less lines of code.

MobX supports an array of features for a modern state management system and everything in one package MobX, not more extra dependency.

Why should I use MobX-State-Tree?

MST have many props compare to other state management, Lets’ check few of them,

  • MST offers great compatibility with React Native, ReactJS, VueJS, AngularJS and more JavaScript apps.
  • Instead of messy code everywhere in the app, MST gives centralized stores to quick access and exchange data.
  • Encapsulation - Your data can’t be modified by outside, It can be modified in “actions”. So it’s easy to access but secure from outside access.
  • Runtime type checking - help you to write clean code and prevent users from assigning wrong data to a tree.
  • Whatever you change in the State is tracked and you can create a snapshot of your state at any time.

MobX-State-Tree Installation

As we discussed earlier MobX is State Management and MobX-State-Tree give you structure to store your data. So we need to install mobx, mobx-state-tree.

NPM: npm install mobx mobx-state-tree --save
Yarn: yarn add mobx mobx-state-tree

Let’s create react app,
npx create-react-app todo-app

Now, let’s install dependency,
npm install mobx mobx-state-tree mobx-react-lite

Run ToDo App,
npm run start

Getting Started - MobX Entity

Let’s start by creating a ToDo application. ToDo application have two entities Task and User. Task entity have two attributes, taskName - name of task, taskStatus - to identify tasks completed or not. User entity have two attributes, userID - id of User, userName - name of User.

So, our entities will looks like something,

Task

  • taskName
  • taskStatus

User

  • userID
  • userName

Creating Model

Tree = Type + State - Each tree has a shape (type information) and state (data). Create model with types.model

import { types } from "mobx-state-tree"

const Task = types.model({
    taskName: "",
    taskStatus: false
})

const User = types.model({
    userID: 1,
    userName: ""
})
Enter fullscreen mode Exit fullscreen mode

Creating Model Instance

Simply create instance by calling .create()

import { types, getSnapshot } from "mobx-state-tree"

const Task = types.model({
    taskName: "",
    taskStatus: false
})

const User = types.model({
    userID: 1,
    userName: ""
})

const kpiteng = User.create()
const articleWriting = Task.create({taskName: “Article Writing”})

console.log("User: kpiteng:", getSnapshot(kpiteng))
console.log("Task: articleWriting:", getSnapshot(articleWriting))
Enter fullscreen mode Exit fullscreen mode

Meeting Types

MobX checks runtime type checking, helps developers to identify wrong data passed in argument. This is very helpful while multiple developers are involved in large scale application.

const articleWriting = Task.create({ taskName: "Article Writing", taskStatus: 95 })
Enter fullscreen mode Exit fullscreen mode

Here, You will get an error like, 95 is not assignable to type boolean, as you have take taskStatus as boolean, so you can’t pass integer data type.

const Task = types.model({
    taskName: types.optional(types.string, ""),
    taskStatus: types.optional(types.boolean, false)
})

const User = types.model({
    userID: types.optional(types.number, 1),
    userName: types.optional(types.string, "")
})
Enter fullscreen mode Exit fullscreen mode

The types namespace are derived from the MST package, You can check lots of widely usage types like array, map, maybe, union and many more. You can check various types available in MST.

Now, It’s time to create a root model, let’s combine Task and User model.

import { types } from "mobx-state-tree"

const Task = types.model({
    taskName: types.optional(types.string, ""),
    taskStatus: types.optional(types.boolean, false)
})

const User = types.model({
    userID: types.optional(types.number, 1),
    userName: types.optional(types.string, "")
})

const RootStore = types.model({
    users: types.map(User),
    tasks: types.optional(types.map(Task), {})
})

const store = RootStore.create({
    users: {}
})
Enter fullscreen mode Exit fullscreen mode

Note - If you are not passing default model value on .create() then you must specify default value of in second argument of types.optional(arg1, arg2).

Modifying Data

MST - Tree node only modified in actions only.

const Task = types
    .model({
        taskName: types.optional(types.string, ""),
        taskStatus: types.optional(types.boolean, false)
    })
    .actions(self => ({
        setTaskName(newTaskName) {
            self.taskName = newTaskName
        },

        toggle() {
            self.taskStatus = !self.taskStatus
        }
    }))
const User = types.model({
    userID: types.optional(types.number, 1),
    userName: types.optional(types.string, "")
});

Enter fullscreen mode Exit fullscreen mode
const RootStore = types
    .model({
        users: types.map(User),
        tasks: types.map(Task)
    })
    .actions(self => ({
        addTask(userID, taskName) {
            self.tasks.set(userID, Task.create({ taskName }))
        }
    }));
const store = RootStore.create({
  users: {} 
});

store.addTask(1, "Article Writing");
store.tasks.get(1).toggle();

render(
  <div>{JSON.stringify(getSnapshot(store))}</div>,
  document.getElementById("root")
);
/*
{
  "users": {

  },
  "taks": {
    "1": {
      "taskName": "Article Writing",
      "taskStatus": true
    }
  }
}
*/
Enter fullscreen mode Exit fullscreen mode

Have you noticed self, - self object constructed when an instance of your model is created. It’s this-free, you can access it using self.

Snapshot

Let’s say you want to see the value stored in your state, which means take a look at a snapshot. It's simple using getSnapshot(). Every Time when you update your state and want to check if changes are reflected in the state, you can check using getSnapshot().

To listen to state change you an use this onSnapshot(store, snapshot => console.log(snapshot))

console.log(getSnapshot(store))
/*
{
    "users": {},
    "tasks": {
        "1": {
            "taskName": "Article Writing",
            "taskCompleted": true
        }
    }
}
*/
Enter fullscreen mode Exit fullscreen mode

Snapshot to Model

In the previous step we see we retrieved a snapshot from the model. But Is that possible to restore the model from the snapshot? Yes, it's simple. Let’s see how.

Before that I would like to relate this process with Redux, so you quickly understood. In Redux we have Reducer where we have State - and we initialize State variables with default values, like users: [], tasks: []). Now first time when user open application, we haven’t any snapshot/empty store, so store will refill using model’s default value (default state value). After interaction with the application you have updated values in store. When you come back next time, it will fetch data from the store and refill your model/state. This same process we are going to do here.

In MobX we can achieve this using two different ways, First - by passing default store value, Second - passing store and default store value (snapshot value).

// 1st
const store = RootStore.create({
    users: {},
    tasks: {
        "1": {
            taskName: "Article Writing",
            taskStatus: true
        }
    }
})
Enter fullscreen mode Exit fullscreen mode
// 2nd
applySnapshot(store, {
    users: {},
    tasks: {
        "1": {
            taskName: "Article Writing",
            taskStatus: true
        }
    }
})
Enter fullscreen mode Exit fullscreen mode

Getting to the UI

Now, it’s time to work with the UI, to connect the MST store to React Component we required mobx-react-lite. We are going to use an observer - name it self say everything. It’s simple, it observe store and updates React components/ Render React components whenever anything changed in the store.

import { observer } from 'mobx-react-lite'
import { values } from 'mobx'

const App = observer(props => (
    <div>
        <button onClick={e => props.store.addTask(randomId(), "Article Writing")}>Add Task</button>
        {values(props.store.tasks).map(todo => (
            <div>
                <input type="checkbox" checked={task.taskStatus} onChange={e => task.toggle()} />
                <input type="text" value={task.taskName} onChange={e => task.setTaskName(e.target.value)} />
            </div>
        ))}
    </div>
))
Enter fullscreen mode Exit fullscreen mode

Improving Render Performance

In Previous steps we have rendered Tasks - for each task we have given the option to mark it complete. Now, everytime we check/uncheck the task our UI will render, because we have added an observer. It’s observer duty to update components when anything updates on the store. So, how to avoid this re-rendering situation. It’s simple let's see it.

const TaskView = observer(props => (
    <div>
        <input type="checkbox" checked={props.task.taskStatus} onChange={e => props.task.toggle()} />
        <input
            type="text"
            value={props.task.taskName}
            onChange={e => props.task.setTaskName(e.target.value)}
        />
    </div>
))

const AppView = observer(props => (
    <div>
        <button onClick={e => props.store.addTask(randomId(), "Article Writing")}>Add Task</button>
        {values(props.store.tasks).map(task => (
            <TaskView task={task} />
        ))}
    </div>
))
Enter fullscreen mode Exit fullscreen mode

We have separate business logic for TaskView, Note - we have added an observer in TaskView. So when anyone changes TaskStatus Check/UnCheck, only TaskView will be rendered. AppView only re-render in a case when a new task is added or existing task deleted.

Computed Properties

Till previous steps we are showing Tasks added by User. What I need to do, to show the count of Completed Tasks and Pending Tasks? It’s simple with MobX, add getter property in our model by calling .views, it will count how many Tasks completed and pending. Let’s see the code.

const RootStore = types
    .model({
        users: types.map(User),
        tasks: types.map(Task),
    })
    .views(self => ({
        get pendingTasksCount() {
            return values(self.tasks).filter(task => !task.taskStatus).length
        },
        get completedCount() {
            return values(self.tasks).filter(task => task.done).length
        }
    }))
    .actions(self => ({
        addTask(userID, taskName) {
            self.tasks.set(userID, Task.create({ taskName }))
        }
    }))
Enter fullscreen mode Exit fullscreen mode
const TaskCountView = observer(props => (
    <div>
        {props.store.pendingTaskCount} Pending Tasks, {props.store.completedTaskCount} Completed Tasks
    </div>
))

const AppView = observer(props => (
    <div>
        <button onClick={e => props.store.addTask(randomId(), "Article Writing")}>Add Task</button>
        {values(props.store.tasks).map(task => (
            <TaskView task={task} />
        ))}
        <TaskCountView store={props.store} />
    </div>
))
Enter fullscreen mode Exit fullscreen mode

Do you want to start new project, check React Ignite Boilerplate.

References

Now, it’s time to assign a User for each Task in Tasks. For this we need to tell MST which is the unique attribute (primary key in db language) in each User model instance. You can implement it using types.identifier type composer.

const User = types.model({
    userID: types.identifier,
    userName: types.optional(types.string, "")
})
Enter fullscreen mode Exit fullscreen mode

Now we need to define a reference to the Task Model. It’s simple - you can do it using types.reference(User). Many times it’s a circular reference, so to resolve it we need to use types.late(() => User). It may be possible User entry found null, to resolve that we need to use type.maybe(...), So finally let’s see how code looks like,

const Task = types
    .model({
        taskName: types.optional(types.string, ""),
        taskStatus: types.optional(types.boolean, false),
        user: types.maybe(types.reference(types.late(() => User)))
    })
    .actions(self => ({
        setTaskName(newTaskName) {
            self.taskName = newTaskName
        },
        setUser(user) {
            if (user === "") {
                self.user = undefined
            } else {
                self.user = user
            }
        },
        toggle() {
            self.taskStatus = !self.taskStatus
        }
    }))
Enter fullscreen mode Exit fullscreen mode
const UserPickerView = observer(props => (
    <select value={props.user ? props.user.userID : ""} onChange={e => props.onChange(e.target.value)}>
        <option value="">-none-</option>
        {values(props.store.users).map(user => (
            <option value={user.id}>{user.name}</option>
        ))}
    </select>
))

const TaskView = observer(props => (
    <div>
        <input type="checkbox" checked={props.task.taskStatus} onChange={e => props.task.toggle()} />
        <input
            type="text"
            value={props.task.name}
            onChange={e => props.task.setName(e.target.value)}
        />
        <UserPickerView
            user={props.task.user}
            store={props.store}
            onChange={userID => props.task.setUser(userID)}
        />
    </div>
))

const TaskCountView = observer(props => (
    <div>
        {props.store.pendingTaskCount} Pending Tasks, {props.store.completedTaskCount} Completed Tasks
    </div>
))

const AppView = observer(props => (
    <div>
        <button onClick={e => props.store.addTask(randomId(), "Article Writting")}>Add Task</button>
        {values(props.store.tasks).map(task => (
            <TaskView store={props.store} task={task} />
        ))}
        <TaskCountView store={props.store} />
    </div>
))
Enter fullscreen mode Exit fullscreen mode

We have covered almost all required topics from MobX-State-Tree. MobX provided few sample example, download ToDoMVC - app using React and MST and Bookshop - app with references, identifiers, routing, testing etc.

Thanks for reading Article!

KPITENG | DIGITAL TRANSFORMATION
www.kpiteng.com/blogs | hello@kpiteng.com
Connect | Follow Us On - Linkedin | Facebook | Instagram

Top comments (1)

Collapse
 
rdewolff profile image
Rom

It would be interesting to share some mobx state tree store architecture with us.