MobX is a reactive state management library that makes it easy to adopt the side effect model. Many of the concepts in MobX directly mirror the terminology we encountered earlier. Let's take a quick tour of these building blocks.
A speed tour of MobX
An observable state
The state is at the epicenter of all things happening in the UI. MobX provides a core building block, called the observable, that represents the reactive state of your application. Any JavaScript object can be used to create an observable. We can use the aptly named observable() API as follows:
import {observable} from 'mobx';
let cart = observable({
itemCount: 0,
modified: new Date()
});
In the preceding example, we have created a simple cart object that is also an observable. The observable() API comes from the mobx NPM package. With this simple declaration of an observable, we now have a reactive cart that keeps track of changes happening on any of its properties: itemCount and modified.
Observing the state changes
Observables alone cannot make an interesting system. We also need their counterparts, the observers. MobX gives you three different kinds of observers, each tailor-made for the use cases you will encounter in your application. The core observers are autorun, reaction, and when. We will look at each of them in more detail in the next chapter, but let's introduce autorun for now.
The autorun API takes a function as its input and executes it immediately. It also keeps track of the observables that are used inside the passed-in function. When these tracked observables change, the function is re-executed. What is really beautiful and elegant about this simple setup is that there is no extra work required to track observables and subscribe to any changes. It all just happens automatically. It's not magic, per se, but definitely an intelligent system at work, which we will cover in a later section:
import {observable, autorun} from 'mobx';
let cart = observable({
itemCount: 0,
modified: new Date()
});
autorun(() => {
console.log(`The Cart contains ${cart.itemCount} item(s).`);
});
cart.itemCount++;
// Console output:
The Cart contains 0 item(s).
The Cart contains 1 item(s).
In the preceding example, the arrow-function that was passed into autorun is executed for the first time and also when itemCount is incremented. This results in two console logs being printed. autorun makes the passed-in function (the tracking-function) an observer of the observables it references. In our case, cart.itemCount was being observed and when it was incremented, the tracking function was automatically notified, resulting in the console logs getting printed.
It's time to take action
Although we are mutating cart.itemCount directly, it is definitely not the recommended approach. Remember that the state should not be changed directly and instead should be done via actions. The use of an action also adds vocabulary to the operations that can be performed on the observable state.
In our case, we can call the state mutation that we were doing as an incrementCount action. Let's use the MobX action API to encapsulate the mutation:
import { observable, autorun, action } from 'mobx';
let cart = observable({
itemCount: 0,
modified: new Date(),
});
autorun(() => {
console.log(`The Cart contains ${cart.itemCount} item(s).`);
});
const incrementCount = action(() => {
cart.itemCount++;
});
incrementCount();
The action API takes a function that will be called whenever the action is invoked. It may seem superfluous that we are passing a function into action, when we could just wrap the mutation inside a plain function and call the plain function instead. An astute thought, for sure. Well, there is a good reason for that. Internally, action is doing much more than being a simple wrapper. It ensures that all notifications for state changes are fired, but only after the completion of the action function.
When you are modifying a lot of observables inside your action, you don't want to be notified about every little change immediately. Instead, you want to be able to wait for all changes to complete and then fire the notifications. This makes the system more performant and also reduces the noise of too many notifications, too soon.
Going back to our example, we can see that wrapping it in an action also improves the readability of the code. By giving a specific name to the action (incrementCount) we have added vocabulary to our domain. In doing so, we can abstract the details of what is needed to actually increment the count.
Observables, observers, and actions are at the core of MobX. With these fundamental concepts, we can build some of the most powerful and complex React applications.
Note the striking similarity with the uni-directional data flow that we saw earlier. Observables capture the state of your application. Observers (also called reactions) include both the side effect handlers as well as the UI. The actions are, well, actions that cause a change in the observable state:
A comparison with Redux
If we were to talk about state management in React and we didn't mention Redux, it would be a complete remiss. Redux is an extremely popular state management library and its popularity stems from the fact that it simplified the original Flux architecture that was proposed by Facebook. It got rid of certain actors in Flux, such as dispatchers, which resulted in combining all the stores into one, commonly referred to as the single state tree.
MobX has some conceptual similarities with Redux as far as the data flow is concerned, but that is also where the similarities end. The mechanism adopted by MobX is drastically different than the one taken by Redux. Let's have a brief overview of Redux before we get into a slightly deeper-level comparison.
Redux in a nutshell
The data flow triad that we saw earlier is also applicable to Redux in its entirety. It's in the state update mechanism that Redux adds its own twist. This can be seen in the following figure:
When the UI fires the action, it is dispatched on the store. Inside the store, the action first passes through one or more middleware, where it can get acted upon and swallowed without further propagation. If the action passes through the middleware, it is sent to one or more reducers, where it can be processed to produce a new state of the store.
This new state of the store is notified to all of the subscribers, with the UI being one of them. If the state is different from the previous value that the UI has, the UI is re-rendered and brought in sync with the new state.
There are few things here that are worth highlighting:
- From the point where the action enters the store, until the new state is computed, the entire process is synchronous.
- Reducers are pure functions that take in the action and the previous state and produce the new state. Since they are pure functions, you cannot put side effects, such as a network call, inside a reducer.
- Middleware is the only place where side effects can be performed, eventually resulting in an action being dispatched on the store.
If you are using Redux with React, which is the most likely combination, there is a utility library called react-redux, which can glue the store with React components. It does this through a function called connect(), which binds the store with the passed in React component. Inside connect(), the React component subscribes to the store for state-change notifications. Binding to the store via connect() means that every state change is notified to every component. This requires adding additional abstractions, such as a state-selector (using mapStateToProps) or implementing shouldComponentUpdate() to receive only the relevant state updates:
connect(mapStateToProps, mapDispatchToProps, mergeProps, options)(Component)
We are deliberately skipping over a few other details that are required for a complete React-Redux setup, but the essentials are in place for a deeper comparison of Redux with MobX.
MobX versus Redux
In principle, MobX and Redux accomplish the same goal of providing a uni-directional data flow. The store is the central actor that manages all state changes and notifies the UI and other observers of the change in state. The mechanism to achieve that is quite different between MobX and Redux.
Redux relies on immutable state snapshots and reference-comparisons between two state snapshots to check for changes. In contrast, MobX thrives on mutable state and uses a granular notification system to track state changes. This fundamental difference in approach has implications on the Developer eXperience (DX) of using each framework. We will use the DX of building a single feature to perform a MobX versus Redux comparison.
Let's start with Redux first. Here is the list of things you have to do when working with Redux:
- Define the shape of the state tree that will be encapsulated in the store. This is normally called initialState.
- Identify all actions that can be performed to change this state tree. Each action is defined in the form { type: string, payload: any }. The type property is used to identify the action and payload is additional data that is carried along with the action. The action types are usually created as string constants and exported from a module.
- Defining raw actions every time you need to dispatch them becomes very verbose. Instead, the convention is to have an action-creator function that wraps the details of the action type and takes in the payload as an argument.
- Use the connect method to wire the React component with the store. Since every state change is notified to every component, you have to be careful to not re-render your component unnecessarily. The render should only happen when the part of the state that the component actually renders has changed (via mapStateToProps). Since every state change is notified to all connected components, it might be expensive to compute mapStateToProps every single time. To minimize these computations, it is recommended to use state selector libraries such as reselect. This increases the effort required to properly set up a performant React component. If you don't use these libraries, you have to take the onus of writing an efficient shouldComponentUpdate hook for the React component.
- Inside every reducer, you have to make sure that you are always returning a new instance of the state anytime there is a change. Note that the reducers are usually kept separate from the initialState definition and that requires going back and forth to ensure the proper state is changed in each of the reducer actions.
- Any side effect you want to perform has to be wrapped in middleware. For more complex side effects which involve async operations, it is better to rely on dedicated middleware libraries, such as redux-thunk, redux-saga, or redux-observables. Note that this also complicates how side effects are constructed and executed. Each of the previously mentioned middleware have their own conventions and terminology. Additionally, the place where an action is dispatched is not co-located with the place where the actual side effect is handled. This results in more jumping around files to construct the mental model of how a feature is put together.
- As the complexity of the feature increases, there is more fragmentation between actions, action-creators, middlewares, reducers, and initialState. Not having things co-located also increases the effort needed to develop a crisp mental model of a how a feature is put together.
In the MobX world, the developer experience is quite different. You will see more of this as we explore MobX throughout this book, but here is the top-level scoop:
- Define the observable state for the feature in a store class. The various properties that can be changed and should be observed are marked with the observable API.
- Define actions that will be needed to mutate the observable state.
- Define all of the side effects (autorun, reaction and when) within the same feature class. The co-location of actions, reactions, and the observable state keeps the mental model crisp. MobX also supports async state updates out of the box, so no additional middleware libraries are needed to manage it.
- Use the mobx-react package that includes the observer API, which allows the React components to connect to the observable store. You can sprinkle observer components throughout your React component tree, which is in fact the recommended approach to fine-tune component updates.
- The advantage of using the observer is that there is no extra work needed to make the component efficient. Internally, the observer API ensures that the component is only updated when the rendered observable state changes.
MobX shifts your mindset to think of the observable state and the corresponding React components. You don't have to focus much on the wiring needed to achieve this. It is abstracted away behind simple and elegant APIs, such as observable, action, autorun, and observer.
We can go as far as saying that MobX enables a more declarative form of Redux. There are no action creators, reducers, or middleware to handle actions and produce the new state. The actions, side effects (reactions), and observable state are co-located inside the class or module. There are no complex connect() methods to glue a React component to the store. A simple observer() does the job with no extra wiring.