How to Improve State Management in React with MobX

What you’ll find in this article:

React Hooks Performance Challenges

Over the last two years, we have developed a powerful new app to drive top-notch GPS hardware for SpotOn. The app allows dog owners to create wireless GPS fences for their pets and to track their location at all times. While scaling up the real-time tracking on map functionalities, we faced some limitations with React Hooks, our state management system. While Hooks is an integrated, simple, and well-defined method that is excellent for managing local states inside a single React component, it became underperforming as the app evolved in functionalities. With a more extensive app, the number of states constantly being updated grew in turn, especially when dealing with the most resource intensive features of the app.

One of these features is Tracking Breadcrumbs. This functionality helps the dog owner see their dog’s path on the map after escaping their fence. Based on the user’s on-device geolocation, our backend service, and Bluetooth characteristics sent from SpotOn’s dog collar, the app draws the path where the dog walked on the map and keeps updating it in real time.

In our initial approach, we had encapsulated these sources as states in the code and read them inside components using Context. Context is slow and inefficient because every component has to be re-rendered again, even if many changes are not relevant to it. With changes in real-time frequency, it could only get worse. And while we needed this map experience to meet our high standards - i.e., seamless and smooth to the user and the lowest impact on performance and resources- React Hooks caused flickers, delays, and unresponsiveness.

And this was just one of the struggles we faced with React Hooks. We can list at least three other challenges:

Stateful Methods That Read or Update States are Complicated to Manage

As the codebase evolves, it’s often confusing to reason when a stateful method will change and how that will impact a useEffect that depends on it.

React Hooks Limit our Flexibility in Code Designing and Structuring

We are forced to use Hooks only inside React components or other Hooks functions. If you want to share Hooks states across modules and hierarchies, Context and Provider is the only eligible approach.

Constant Code Correction

While we want to focus on the features that our clients and users love, with React Hooks, we spent considerable time debugging issues and refactoring code to keep it as clean as possible.

Why Was MobX a Better Option?

Many popular state management libraries for React projects are available these days. They all aim to improve state management, but each focuses on one or more specific areas. Therefore, it’s not about choosing the best library for all; instead, choosing the one that best fits your project. At Whitespectre, we chose MobX for a recent project because it stands out in these ways:

Minimal Concepts

We can testify that MobX’s motto -“Simple, scalable state management”- is 100% true. With MobX, all you need to get started is to define some states, observe them in components, and update them using good old Javascript assignments. No boilerplate code is vital for teams who want every developer to be on board with a smooth learning curve and transition.

Higher Performance

As the project evolves, we have an ever-growing number of states referenced by a hierarchy of components. Since refreshing components is a task that heavily consumes time and device resources, being able to reduce unnecessary component refreshments can significantly improve performance. We often only need to update a small subset of them, and MobX ensures that only the components that reference those states are refreshed while the rest of the screen remains unchanged.

Flexible and Compatible

MobX allows you to update states anywhere in code, not just inside components. You can separate the state and logic from your components following any architecture you prefer. Moreover, MobX works well with the existing React Hooks code, and a single component can be driven by both MobX states and Hooks states. In the next section, we present an implementation pattern that helped us gradually migrate to MobX without breaking the other code while keeping the project scalable and easy to maintain as we add more features and complexities.

How We Successfully Migrated to MobX from Hooks

All transitions can be tortuous if not handled properly, and even more for this state management migration, where data and states drive the whole application. A trivial mistake during the transition can result in significant headaches in user experience. We considered the learning curve, the refactoring effort, and the compatibility of our project. With the following steps, you can gradually move between Hooks and MobX without breaking your users' experiences.

Defining MobX Stores

Before we migrated to MobX, we managed states with standard useState from React Hooks and shared them across modules and components through Context. For example, for a to-do list app where we want to show a list of to-do items and allow the user to add more or remove existing items, we will end up having a file called useTodo.tsx with the following implementation:

import React, { 
  createContext,
  FC,
  useCallback,
  useContext,
  useState
} from 'react';

type Item = {
  id: string;
  title: string;
};

type TodoContextValue = {
  items: Item[];
  addItem: (title: string) => void;
  removeItem: (id: string) => void;
};

export const TodoContext = createContext<TodoContextValue>(null!);

export const TodoProvider: FC<{}> = ({ children }) => {
  const [items, setItems] = useState<Item[]>([]);

  const addItem = useCallback(
    (title: string) => {
      setItems(items.concat({ id: Date.now().toString(), title }));
    },
    [items],
  );

  const removeItem = useCallback(
    (id: string) => {
      setItems(items.filter(item => item.id !== id));
    },
    [items],
  );

  const value = { items, addItem, removeItem };

  return 
    <TodoContext.Provider value={value} >
      {children}
    </TodoContext.Provider>;
};

export const useTodo = () => useContext(TodoContext);

Here we have items, a state created with useState, representing all the to-do items in the app. In addition, we have two methods to allow adding new items and removing existing items. We expose all of them to children components of TodoProvider, which will be mounted at the root tree of the app. This is how we define a group of related states and actions on them and how we share them in a Hooks based project.

Luckily, MobX encourages similar patterns. Following the defining data stores section of MobX, we define MobX stores for feature modules and shared global modules. We can refactor the Hooks implementation above into a MobX store:

import { makeAutoObservable } from 'mobx';

type Item = {
  id: string;
  title: string;
};

export class TodoStore {
  items: Item[] = [];

  constructor() {
    makeAutoObservable(this);
  }

  addItem = (title: string) => {
    this.items = this.items.concat({ id: Date.now().toString(), title })
  };

  removeItem = (id: string) => {
    this.items = this.items.filter(item => item.id !== id);
  };
}

Here we maintained the same functionalities of this to-do list app, which include items, addItem(), and removeItem(), but MobX now drives them. The makeAutoObservable(this) line in the constructor is the key part. It’ll expose all the properties (items in this case) as part of your state, and all your functions as actions that modify that state. Other parts of the code can either observe part of the state, or execute actions to modify it. Notice that addItem() and removeItem() are now regular Javascript arrow functions, which means you can use them just like using any other JS functions without worrying about dependencies! On the contrary, in the previous Hooks implementation, they are stateful functions wrapped with useCallbacks which require your attention whenever you use them.

During our migration process, the major work was refactoring Hooks states into MobX stores. Thanks to the similar concepts on both sides, it was very straightforward. After we had stores ready, we needed to establish connections between stores and components.

Reading MobX Stores from React Components

With standard Hooks implementation, reading a state inside a React component is done by retrieving value from a Context. First, we insert TodoProvider into the root tree:

const App = () => {
  return (
    ...
      <TodoProvider>
        ...
      </TodoProvider>
    ...
  );
};

export default App;

Then, we retrieve items from useTodo and render them as needed:

const List = () => {
  const { items } = useTodo();

  return (
    <>
    {
      items.map(item => {
        return <Item item={item} />
      })
    }
    </>
  );
};

What about MobX? Our project has dozens of React hooks files and even more components that are reading them. Our goal was to minimize (or eliminate) the tedious work on the changes on the components side.

TodoStore defined above is a plain Javascript class, and MobX doesn’t restrict us on how we structure the code. We can use the same Context approach to share MobX stores just like what we do with Hooks states:

import { makeAutoObservable } from 'mobx';
import React, { createContext, FC, useContext, useState } from 'react';

type Item = {
  id: string;
  title: string;
};

export class TodoStore {
  items: Item[] = [];

  constructor() {
    makeAutoObservable(this);
  }

  addItem = (title: string) => {
    this.items = this.items.concat({ id: Date.now().toString(), title })
  };

  removeItem = (id: string) => {
    this.items = this.items.filter(item => item.id !== id);
  };
}

export const TodoContext = createContext<TodoStore>(null!);

export const TodoProvider: FC<{}> = ({ children }) => {
  const [store] = useState(new TodoStore());
  return <TodoContext.Provider value={store}>{children}</TodoContext.Provider>;
};

export const useTodo = () => useContext(TodoContext);

Below the store definition, we added several lines to create a Context and a Provider for this store. The only difference is that here we only share a single store object instance as the value and do not update itself throughout the lifecycle of TodoProvider. If you take a look, these lines are basically a boilerplate that can be copied and pasted for any other new MobX stores.

The final step is to use observer() from MobX’s bindings for React to wrap all the components, and it’s done! Without any changes to components, we finished the migration by substituting the implementation of TodoProvider from pure Hooks states to a MobX store.

Can you tell whether the useTodo code below is a collection of Hooks states or a MobX store?

const List = observer(() => {
  const { items } = useTodo();

  return (
    <>
    {
      items.map(item => {
        return <Item item={item} />
      })
    }
    </>
  );
});

Final Thoughts on MobX

1. We love Hooks, and we still do! Hooks are great for managing local states inside a single React component. Moreover, If you are writing a library for public or internal teams, Hooks helps you deliver a clean codebase that fits most projects.

2. MobX and Hooks can live in peace. It often takes enormous efforts and time to refactor an entire project to a new solution, especially with state management that drives every data shown on screen. With the implementation in this article, you can gradually move between Hooks and MobX without breaking your users' experiences.

3. State management with MobX is simple and fun. If you want to adopt it to your React projects, check out MobX website where you can find basics and pro tips that are not covered in this article, so you have maximum performance and clean code without compromising each other.

Let’s Chat