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.
Ready? Let’s get started.
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.
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.
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; }
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 {}
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.
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:
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
.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.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
.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!
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.
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.
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;
Our React app will have three pages.
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:
postId
using useParams
hook from react-router-dom
post.comments
relationship that we create in our model to get and render all comments for postAnd 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.
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.
GitHub – varunpvp/react-mobx-app: Blog app build with React, React Hooks, Typescript and Mobx at scale
Blog app build with React, React Hooks, Typescript and Mobx at scale – varunpvp/react-mobx-app
Install LogRocket via npm or script tag. LogRocket.init()
must be called client-side, not
server-side
$ npm i --save logrocket // Code: import LogRocket from 'logrocket'; LogRocket.init('app/id');
// Add to your HTML: <script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script> <script>window.LogRocket && window.LogRocket.init('app/id');</script>
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowBuild scalable admin dashboards with Filament and Laravel using Form Builder, Notifications, and Actions for clean, interactive panels.
Break down the parts of a URL and explore APIs for working with them in JavaScript, parsing them, building query strings, checking their validity, etc.
In this guide, explore lazy loading and error loading as two techniques for fetching data in React apps.
Deno is a popular JavaScript runtime, and it recently launched version 2.0 with several new features, bug fixes, and improvements […]
4 Replies to "Using MobX for large-scale enterprise state management"
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.
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.
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.
Sorry I didn’t get the point that why we need to keep the Context layer? Can’t we just export/import the store and api directly?