Varun Pujari Software Engineer | IoT Geek | Always ready for a game of chess

Using MobX for large-scale enterprise state management

13 min read 3651

Enterprise State Management with MobX

When it comes to building larger apps, it’s crucial the app state is well-structured and clearly defined. In this post, we’ll cover how can we use MobX for large-scale applications. We’re going to focus on how to structure app state, define relationships between data, make network calls, and load data in store.

Because this article is focused on state management, we won’t spend much time on create UI/styling components.

Prerequisites

Ready? Let’s get started.

Setting up your enterprise app in React and MobX

First, create a TypeScript React app with create-react-app.

$ npx create-react-app react-mobx-app --template=typescript

Now, go the app folder and install the following:

$ yarn add react-router-dom mobx mobx-react axios

Some of these need types, so let’s install them as well.

$ yarn add -D @types/react-router-dom 

Let’s delete the files we don’t need. We can delete App.tsx, App.test.tsx, App.css, and logo.svg. We’ll create the necessary files again later.

Before we start building the app, let’s have a look at what we are going build and the store structure. We’re going to build a simple blog app. The app will have three entities, namely, users, posts, and comments. Here’s the relationship between them.

  • User (has many posts)
    • Post (has many comments and belongs to a user)
      • Comment (belongs to a post)

Creating entity types

Now that we know the structure, let’s create types for it. We’re going to put types in a folder called types under src.

Let’s start by creating user.ts under types. This is how it looks.

We made a custom demo for .
No really. Click here to check it out.

export default interface IUser {
  id: number;
  name: string;
  username: string;
  email: string;
}

Then, types/post.ts:

export default interface IPost {
  id: number;
  userId: number;
  title: string;
  body: string;
}

And finally, types/comment.ts:

export default interface IComment {
  id: number;
  postId: number;
  name: string;
  email: string;
  body: string;
}

The app store

Now we need to create models. These models will implement the above types and also define relationship between other entities. To define the relationship, these models will need access to app store. But we haven’t create the app store yet. Let’s do it now.

Create a folder called stores under src under the stores folder. Now, create a file named app.ts. This file is going to contain all the stores of the app. For now, let’s make it an empty class, like so:

export default class AppStore {}

Creating models

Now that we have the app store, let’s create a folder called models under src and start implementing our models.

First, the user model (models/user.ts):

import AppStore from "../stores/app";
import IUser from "../types/user";

export default class User implements IUser {

  id: number;
  name: string;
  username: string;
  email: string;

  constructor(private store: AppStore, user: IUser) {
    this.id = user.id;
    this.name = user.name;
    this.username = user.username;
    this.email = user.email;
  }
}

Our user model implements the user type and the constructor takes two parameters. First, the AppStore for defining relationships, and second, user type to instantiate the member variables.

In the same way, let’s create a post model and a comment model.

Here’s the code for our post model (models/post.ts):

import AppStore from "../stores/app";
import IPost from "../types/post";

export default class Post implements IPost {

  id: number;
  userId: number;
  title: string;
  body: string;

  constructor(private store: AppStore, post: IPost) {
    this.id = post.id;
    this.userId = post.userId;
    this.title = post.title;
    this.body = post.body;
  }
}

And here’s our comment model (models/comment.ts):

import AppStore from "../stores/app";
import IComment from "../types/comment";

export default class Comment implements IComment {

  id: number;
  postId: number;
  name: string;
  email: string;
  body: string;

  constructor(private store: AppStore, comment: IComment) {
    this.id = comment.id;
    this.postId = comment.postId;
    this.name = comment.name;
    this.email = comment.email;
    this.body = comment.body;
  }
}

But our models are missing something — the relationships. We haven’t defined any relationships between them, but we’ll do so after we create the stores.

Creating stores in MobX

Let’s create the stores, starting with the user store. Create a file called user.ts in the stores folder. Paste in the below code:

import User from "../models/user";
import IUser from "../types/user";
import AppStore from "./app";

export default class UserStore {

  byId = new Map<number, User>();

  constructor(private store: AppStore) {}

  load(users: IUser[]) {
    users.forEach((it) => this.byId.set(it.id, new User(this.store, it)));
  }

  get all() {
    return Array.from(this.byId.values());
  }
}

Let me explain what’s happening here.

First, we have a map called byId. It’s going to store all of our user records keyed by id. Why map? Because it’s easier to get, update, and remove a record.

The constructor again takes in AppStore so it can pass it to the model instance.

Next, we have a load method, which takes in an array of IUser type and loads it into the byId map by instantiating user model.

And lastly, we have a getter property called all. It returns all the user records that are available in store.

Notice, that our store is plain class. We haven’t made it a MobX store.

To do so, we’ll make the following changes to our store:

  1. Change the byId member variable type from Map to observable.map from MobX. By making a property observable, we tell MobX to watch for changes and to re-render the components if necessary. In our case, we’re using observable.map, which means we’ll receive an update when a record is added, updated, or removed from map.
  2. The load method is loading the data into the store. We have to tell MobX that we are updating the observable by decorating the method with action.
  3. The all getter property is actually derived from byId observable. We have to let MobX know that is a computed property by decorating it with computed.
  4. Finally, we have to call the makeObservable function passing this instance in the constructor to make everything work.

After making the above changes, this is how our store will look.

import {
  action,
  computed,
  makeObservable,
  observable,
  ObservableMap,
} from "mobx";
import User from "../models/user";
import IUser from "../types/user";
import AppStore from "./app";

export default class UserStore {

  byId = observable.map<number, User>();

  constructor(private store: AppStore) {
    makeObservable(this);
  }

  @action load(users: IUser[]) {
    users.forEach((it) => this.byId.set(it.id, new User(this.store, it)));
  }

  @computed get all() {
    return Array.from(this.byId.values());
  }
}

In the same manner, let’s create PostStore and CommentStore.

For stores/post.ts:

import {
  action,
  computed,
  makeObservable,
  observable,
  ObservableMap,
} from "mobx";
import Post from "../models/post";
import IPost from "../types/post";
import AppStore from "./app";

export default class PostStore {

  byId = new observable.map<number, Post>();

  constructor(private store: AppStore) {
    makeObservable(this);
  }

  @action load(posts: IPost[]) {
    posts.forEach((it) => this.byId.set(it.id, new Post(this.store, it)));
  }

  @computed get all() {
    return Array.from(this.byId.values());
  }
}

Now, for stores/comment.ts:

import {
  action,
  computed,
  makeObservable,
  observable,
  ObservableMap,
} from "mobx";

import IComment from "../types/comment";
import Comment from "../models/comment";
import AppStore from "./app";

export default class CommentStore {

  byId = new observable.map<number, Comment>();

  constructor(private store: AppStore) {
    makeObservable(this);
  }

  @action load(comments: IComment[]) {
    comments.forEach((it) => this.byId.set(it.id, new Comment(this.store, it)));
  }

  @computed get all() {
    return Array.from(this.byId.values());
  }
}

As our stores are created, let’s instantiate them in AppStore like this:

import CommentStore from "./comment";
import PostStore from "./post";
import UserStore from "./user";

export default class AppStore {
  user = new UserStore(this);
  post = new PostStore(this);
  comment = new CommentStore(this);
} 

Now, let’s get back to defining relationship between our models.

User model: As we discussed earlier, the user will have many posts. Let’s code a relationship for that.

import AppStore from "../stores/app";
import IUser from "../types/user";

export default class User implements IUser {

  id: number;
  name: string;
  username: string;
  email: string;

  constructor(private store: AppStore, user: IUser) {
    this.id = user.id;
    this.name = user.name;
    this.username = user.username;
    this.email = user.email;
  }

  get posts() {
    return this.store.post.all.filter((it) => it.userId === this.id);
  }
}

If you look at our posts relationship, you’ll notice that it’s a computed property derived from post.all computed property. We have to decorate it with computed decorator for MobX to compute it and call makeObservable in the constructor to tell MobX that this class has observable properties.

Here is the final version:

import { computed, makeObservable } from "mobx";
import AppStore from "../stores/app";
import IUser from "../types/user";

export default class User implements IUser {
  id: number;
  name: string;
  username: string;
  email: string;

  constructor(private store: AppStore, user: IUser) {
    this.id = user.id;
    this.name = user.name;
    this.username = user.username;
    this.email = user.email;

    makeObservable(this);
  }

  @computed get posts() {
    return this.store.post.all.filter((it) => it.userId === this.id);
  }
}

Now let’s code the other two models as well.

Post model:

import { computed, makeObservable } from "mobx";
import AppStore from "../stores/app";
import IPost from "../types/post";

export default class Post implements IPost {
  id: number;
  userId: number;
  title: string;
  body: string;

  constructor(private store: AppStore, post: IPost) {
    this.id = post.id;
    this.userId = post.userId;
    this.title = post.title;
    this.body = post.body;

    makeObservable(this);
  }

  @computed get user() {
    return this.store.user.byId.get(this.userId);
  }

  @computed get comments() {
    return this.store.comment.all.filter((it) => it.postId === this.id);
  }
}

Comment model:

import { computed, makeObservable } from "mobx";
import AppStore from "../stores/app";
import IComment from "../types/comment";

export default class Comment implements IComment {
  id: number;
  postId: number;
  name: string;
  email: string;
  body: string;

  constructor(private store: AppStore, comment: IComment) {
    this.id = comment.id;
    this.postId = comment.postId;
    this.name = comment.name;
    this.email = comment.email;
    this.body = comment.body;

    makeObservable(this);
  }

  @computed get post() {
    return this.store.post.byId.get(this.postId);
  }
}

With that, we have successfully created entire store for our app!

Coding the network layer

So far, we have created stores for our app, but the network layer is pending. Let’s now code our app network layer, which will be responsible for making network calls and loading the data in stores.

We are going to completely separate network layer from stores. The store will not know where the data is loaded from.

Let’s start off by creating a main AppApi class in file app.ts under src/apis folder. It will contain network calls of other resources.

Here’s the code for src/apis/app.ts.

import axios from "axios";
import AppStore from "../stores/app";

export default class AppApi {

  client = axios.create({ baseURL: "https://jsonplaceholder.typicode.com" });

  constructor(store: AppStore) {}
}

We are also creating an axios client member variable, which will be used to make network calls.

We will be using JSONPlaceholder Fake REST API to get the data and setting the base URL to https://jsonplaceholder.typicode.com for our axios client.

The AppStore is passed to us in the constructor. We will be using AppStore to load the data into the stores after getting data from API.

Now, we have our AppApi. Let’s write network calls for your individual resources. We’ll start with the user. First, create a file called user.ts under src/apis folder and paste in the below code:

import AppStore from "../stores/app";
import AppApi from "./app";

export default class UserApi {

  constructor(private api: AppApi, private store: AppStore) {}

  async getAll() {
    const res = await this.api.client.get(`/users`);
    this.store.user.load(res.data);
  }

  async getById(id: number) {
    const res = await this.api.client.get(`/users/${id}`);
    this.store.user.load([res.data]);
  }
}

Let’s understand what is happening here.

First, we create a UserApi class, taking in AppApi and AppStore as constructor arguments. Then, we will use the AppApi instance to get axios client and make network calls. After getting the data, we are using AppStore to load the data into store.

Let’s look at the methods individually:

getAll – sends a get request to /users. The response is returned as an array of user objects, which are loaded into the store using the store’s load method.

getById – sends a get request to /users/$id to get a single user record. The response is returned as a single user object, so we wrap the user object in an array by putting square brackets before passing it to the store’s load method.

Similarly, we will create two other API clients for the two other resources, post and comment.

Here’s the code for our post apis/post.ts:

import AppStore from "../stores/app";
import AppApi from "./app";

export default class PostApi {

  constructor(private api: AppApi, private store: AppStore) {}

  async getAll() {
    const res = await this.api.client.get(`/posts`);
    this.store.post.load(res.data);
  }

  async getById(id: number) {
    const res = await this.api.client.get(`/posts/${id}`);
    this.store.post.load([res.data]);
  }

  async getByUserId(userId: number) {
    const res = await this.api.client.get(`/posts?userId=${userId}`);
    this.store.post.load(res.data);
  }
}

And for comment apis/comment.ts:

import AppStore from "../stores/app";
import AppApi from "./app";

export default class CommentApi {

  constructor(private api: AppApi, private store: AppStore) {}

  async getByPostId(postId: number) {
    const res = await this.api.client.get(`/posts/${postId}/comments`);
    this.store.comment.load(res.data);
  }
}

Note that I’m only writing methods for required API calls. You can add or remove calls based on your requirement.

Now, we have finished writing API clients, so let’s register them in AppApi class.

import axios from "axios";
import AppStore from "../stores/app";
import CommentApi from "./comment";
import PostApi from "./post";
import UserApi from "./user";

export default class AppApi {

  client = axios.create({ baseURL: "https://jsonplaceholder.typicode.com" });

  user: UserApi;
  post: PostApi;
  comment: CommentApi;

  constructor(store: AppStore) {
    this.user = new UserApi(this, store);
    this.post = new PostApi(this, store);
    this.comment = new CommentApi(this, store);
  }
}

We are done creating the network layer for our entire app.

App context to use stores and APIs

We created the stores and network layer for our blog app, but there has to be a way for us to use them and API in our React components. For that, we will use React Context to provide the store and API to our components.

import React, { useContext } from "react";
import AppApi from "./apis/app";
import AppStore from "./stores/app";

interface AppContextType {
  store: AppStore;
  api: AppApi;
}

const AppContext = React.createContext<null | AppContextType>(null);

export const useAppContext = () => {
  const context = useContext(AppContext);
  return context as AppContextType;
};

export default AppContext;

This is pretty straightforward. We create a type for context, which has two properties: store and api.

Then we create a React Context called AppContext to provide store and api to our app. Finally, we have a useAppContext custom React hook to utilise the store and api in our components.

Let’s put the above code in app-context.ts file under src folder.

Using components with MobX

Because we’re not focusing on UI, components are straightforward. We will be using basic React concepts like useEffect for component mount callback, useParams from react-router-dom to get params from URL, and we will be passing model instances to components as props to display data.

Let’s start by creating the comment component. We will put the components file under src/components with the name, comment.tsx.

import CommentModel from "../models/comment";

const Comment: React.FC<{ comment: CommentModel }> = ({ comment }) => {
  return (
    <div>
      <strong>
        {comment.name} • {comment.email}
      </strong>
      <p>{comment.body}</p>
      <br />
    </div>
  );
};

export default Comment;

Our component is missing something very important. We have to let MobX know that our component will be observing changes being done to the observables from the store.

To do that, we will wrapping our React components with observer from mobx-react package. Here is what the updated component will look like:

import { observer } from "mobx-react";
import CommentModel from "../models/comment";

const Comment: React.FC<{ comment: CommentModel }> = observer(({ comment }) => {
  return (
    <div>
      <strong>
        {comment.name} • {comment.email}
      </strong>
      <p>{comment.body}</p>
      <br />
    </div>
  );
});

export default Comment;

And the post component (components/post.tsx):

import { observer } from "mobx-react";
import React from "react";
import { Link } from "react-router-dom";
import PostModel from "../models/post";

const Post: React.FC<{ post: PostModel; ellipsisBody?: boolean }> = observer(
  ({ post, ellipsisBody = true }) => {
    return (
      <div>
        <h2>{post.title}</h2>
        <p>
          {ellipsisBody ? post.body.substr(0, 100) : post.body}
          {ellipsisBody && (
            <span>
              ...<Link to={`/post/${post.id}`}>read more</Link>
            </span>
          )}
        </p>
        <p>
          Written by <Link to={`/user/${post.userId}`}>{post.user?.name}</Link>
        </p>
      </div>
    );
  }
);

export default Post;

Building pages in our React app

Our React app will have three pages.

  1. Home page – displays a list of posts
  2. Post page – displays the post content and comments it received
  3. User page – displays user information and a list of posts written by the user

Home page

Put the home page file under pages/home.tsx.

import { observer } from "mobx-react";
import { useEffect, useState } from "react";
import { useAppContext } from "../app-context";
import Post from "../components/post";

const HomePage = observer(() => {
  const { api, store } = useAppContext();
  const [loading, setLoading] = useState(false);

  const load = async () => {
    try {
      setLoading(true);
      await api.post.getAll();
      await api.user.getAll();
    } finally {
      setLoading(false);
    }
  };

  useEffect(() => {
    load();
  }, []);

  if (loading) {
    return <div>loading...</div>;
  }

  return (
    <div>
      <h1>Posts</h1>
      {store.post.all.map((post) => (
        <Post key={post.id} post={post} />
      ))}
    </div>
  );
});

export default HomePage;

Here, we are using the useAppContext hook to get store and api. Notice we have a useState hook to store the loading state of our API calls. After that, we have a load function, which sets loading to true, and all the posts and users and sets loading false.

Finally, useEffect with an empty array as deps to create a component mount effect and calling the load function in it.

Let’s code other remaining pages:

Post page (pages/post.tsx)

import { observer } from "mobx-react";
import { useEffect, useState } from "react";
import { useParams } from "react-router";
import { useAppContext } from "../app-context";
import Post from "../components/post";

const PostPage = observer(() => {

  const { api, store } = useAppContext();

  const [loading, setLoading] = useState(false);

  const params = useParams<{ postId: string }>();

  const postId = Number(params.postId);

  const load = async () => {
    try {
      setLoading(true);
      await api.post.getById(postId);
      await api.comment.getByPostId(postId);
    } finally {
      setLoading(false);
    }
  };

  useEffect(() => {
    load();
  }, []);

  if (loading) {
    return <div>loading...</div>;
  }

  const post = store.post.byId.get(Number(params.postId));

  if (!post) {
    return <div>Post not found</div>;
  }

  return (
    <div>
      <Post ellipsisBody={false} post={post} />
      <h2>Comments </h2>
      {post.comments.map((comment) => (
        <Comment key={comment.id} comment={comment} />
      ))}
    </div>
  );
});

export default PostPage;

The only differences we have here are:

  1. We are getting the postId using useParams hook from react-router-dom
  2. We are loading the post and comments for the post
  3. In the rendering, we are using the post.comments relationship that we create in our model to get and render all comments for post

And finally, let’s code the user page.

import { observer } from "mobx-react";
import { useEffect, useState } from "react";
import { useParams } from "react-router";
import { useAppContext } from "../app-context";
import Post from "../components/post";

const UserPage = observer(() => {
  const { api, store } = useAppContext();
  const [loading, setLoading] = useState(false);
  const params = useParams<{ userId: string }>();
  const userId = Number(params.userId);

  const load = async () => {
    try {
      setLoading(true);
      await api.user.getById(userId);
      await api.post.getByUserId(userId);
    } finally {
      setLoading(false);
    }
  };

  useEffect(() => {
    load();
  }, []);

  if (loading) {
    return <div>loading...</div>;
  }

  const user = store.user.byId.get(userId);

  if (!user) {
    return <div>User not found</div>;
  }

  return (
    <div>
      <h3>
        {user.name} • {user.username}
      </h3>
      <p>{user.email}</p>
      <h2>Posts</h2>
      {user.posts.map((post) => (
        <Post key={post.id} post={post} />
      ))}
    </div>
  );
});

export default UserPage;

With this, we have successfully finished our entire app!

Now only one more item is left: the root app component. Remember, we deleted the App.tsx file when we started. Let’s add that back under src folder.

app.tsx

import { BrowserRouter, Route, Switch } from "react-router-dom";
import AppContext from "./app-context";
import AppStore from "./stores/app";
import AppApi from "./apis/app";
import HomePage from "./pages/home";
import PostPage from "./pages/post";
import UserPage from "./pages/user";

const store = new AppStore();
const api = new AppApi(store);

function App() {
  return (
    <AppContext.Provider value={{ store, api }}>
      <BrowserRouter>
        <Switch>
          <Route path="/user/:userId" component={UserPage} />
          <Route path="/post/:postId" component={PostPage} />
          <Route path="/" component={HomePage} />
        </Switch>
      </BrowserRouter>
    </AppContext.Provider>
  );
}

export default App;

We are instantiating the store and API, and providing it to our app through AppContext.Provider.

Using BrowserRouter from react-router-dom, we’ll render our pages. That’s it. If you run and open the app in a browser, you should see the app working.

Conclusion

In this tutorial, we learned how to manage large-scale React state using MobX. Thanks for reading and reach out to me if you have any questions!

You can get the source code for this entire project here or test out the live app.

varunpvp/react-mobx-app

Blog app build with React, React Hooks, Typescript and Mobx at scale – varunpvp/react-mobx-app

 

Full visibility into production React apps

Debugging React applications can be difficult, especially when users experience issues that are difficult to reproduce. If you’re interested in monitoring and tracking Redux state, automatically surfacing JavaScript errors, and tracking slow network requests and component load time, try LogRocket.

LogRocket is like a DVR for web apps, recording literally everything that happens on your React app. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app's performance, reporting with metrics like client CPU load, client memory usage, and more.

The LogRocket Redux middleware package adds an extra layer of visibility into your user sessions. LogRocket logs all actions and state from your Redux stores.

Modernize how you debug your React apps — .

Varun Pujari Software Engineer | IoT Geek | Always ready for a game of chess

3 Replies to “Using MobX for large-scale enterprise state management”

  1. This is amazing tutorial.
    Thank you so much!

    Unfortunately, I didn’t get the main point of using MobX in this exact tutorial. I know it is a tutorial about using MobX but I would like to see the benefits of using MobX here, please.

  2. Thank you. I like idea with private store inside other stores!

    Also, to ANJD, I didn’t get the main point of using Redux from your reply, I would like to see the benefits of using Redux here, please.

  3. What bothers me about this project is this process:
    – The view calls posts and comments APIs
    – APIs fill stores
    – The model refers to stores
    – The view gets the model
    Therefore the view handle the logic of domain services. But this is not its role to know that the comments API have to be called to construct the post model.
    So i think a layer – like a Controller or a ViewModel – is missing to separate the presentation to the logic.

Leave a Reply