MobX is a statement management library. Unlike Redux it doesn't require a lot of boilerplate code. In this post we'll talk how you can benefit from using MobX vs Redux.
There's a video version of this article that I originally recorded for the React Native London. If you prefer to read text, just scroll below.
Redux is great for extracting application state and business logic out of components. However, you end up with a lot of boilerplate. Your code will be scattered around many different place. Think of a typical user action - usually, you have to write an action definition, an action creator, and finally a reducer. Now, imagine you have a bug in that action - you'll have to trace it in at least two different places - an action creator and a reducer.
This tweet by Ben Lesh - a member of the RxJS core team - perfectly summarises that.
MobX allows you to manage your state in a far more concise way. It's a fairly simple library that you can get started with in almost no time. It's got more than 400k+ weekly downloads on NPM. And many companies, including mine, use it in production.
Unlike, Redux, it's not afraid to mutate state. In fact, it's based on the observer pattern which is all about mutations and reactions to them.
Instead of doing a theoretical introduction of MobX, I'll use an example. We'll build a simple application first with Redux and then'll we'll move it to Mobx, while gradually explaining its concepts.
The sample app is a classis todo app:
First of all, the Redux app needs action creators.
There'll be two action creators:
addTodo()
getTodos()
Since we need to send API requests, there'll be a bit of complexity - we'll have to return a function an async
function from the action creators.
store/action-creators.js
import { GET_TODOS } from "./constants";
export const addTodo = (todo) => {
return async (dispatch) => {
await fetch("http://localhost:9999/todos", {
method: "post",
body: todo,
});
dispatch(getTodos());
};
};
export const getTodos = () => {
return async (dispatch) => {
const res = await fetch("http://localhost:9999/todos");
const { todos } = await res.json();
dispatch({
type: GET_TODOS,
todos,
});
};
};
Then we need to add reducers that will set the initial state and modify it once the actions are dispatched.
store/reducers.js
import { ADD_TODO, GET_TODOS } from "./constants";
const initialState = {
todos: [],
};
const todos = (state = initialState, action) => {
switch (action.type) {
case ADD_TODO: {
return {
...state,
todos: [...state.todos, action.todo],
};
}
case GET_TODOS: {
return {
...state,
todos: action.todos,
};
}
default:
return state;
}
};
We need to throw a few constants in the mix, so that the reducers module doesn't depend on the action creator one and vice versa.
store/constants.js
export default todos;
export const ADD_TODO = "ADD_TODO";
export const GET_TODOS = "GET_TODOS";
Finally, we need to wire it app together and call createStore()
.
store/store.jsx
import { applyMiddleware, createStore } from "redux";
import thunkMiddleware from "redux-thunk";
import todos from "./reducers";
export default createStore(todos, applyMiddleware(thunkMiddleware));
It feels like we had to write a lot of code for such a small application, doesn't it?
As the final step we have to inject the store into the application context:
index.jsx
import React from "react";
import ReactDOM from "react-dom";
import { Provider } from "react-redux";
import store from "./store/store";
import App from "./App";
ReactDOM.render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>,
document.getElementById("root")
);
What about the components. We left them till the end, but they are not particularly complicated:
Todos/Todos.jsx
import React, { useEffect } from "react";
import { connect } from "react-redux";
import { getTodos } from "../store/action-creators";
import "./Todo.css";
const Todos = ({ todos, getTodos }) => {
useEffect(() => {
getTodos();
}, [getTodos]);
return (
<div className="list">
{todos.map((todo, index) => (
<div key={index} className="todo">
{todo}
</div>
))}
</div>
);
};
const mapStateToProps = (state) => ({
todos: state.todos,
});
const mapDispatchToProps = (dispatch) => ({
getTodos: () => {
dispatch(getTodos());
},
});
export default connect(mapStateToProps, mapDispatchToProps)(Todos);
Todos/Todos.jsx
import React, { useState } from "react";
import { connect } from "react-redux";
import { addTodo } from "../store/action-creators";
import "./NewTodo.css";
const NewTodo = ({ addTodo }) => {
const [todo, setTodo] = useState("");
return (
<div>
<input
type="text"
onChange={(e) => setTodo(e.target.value)}
placeholder="New todo item..."
className="new-todo"
/>
<button onClick={() => addTodo(todo)} className="new-todo-button">
Add a new todo
</button>
</div>
);
};
const mapDispatchToProps = (dispatch) => ({
addTodo: (todo) => dispatch(addTodo(todo)),
});
export default connect(null, mapDispatchToProps)(NewTodo);
Now, remember a very verbose Redux store we wrote? Let's see how we re-write it in MobX.
import { observable, action } from "mobx";
export default class TodoStore {
@observable
todos = [];
@action
async addTodo(todo) {
await fetch("http://localhost:9999/todos", {
method: "post",
body: todo,
});
this.getTodos();
}
@action
async getTodos() {
const res = await fetch("http://localhost:9999/todos");
const { todos } = await res.json();
this.todos = todos;
}
}
And that's it! Those mere 25 lines of code replace Redux's action creators, reducers, and the other bits!
Now, we have a very concise store that both has an application state and business logic, yet doesn't mix them together. Indeed, MobX stores are a great answer to the question - 'Where do I put my business logic and HTTP calls in React?'. Also, MobX stores are extremely easy to unit test.
Okay, but how is it possible? Let's dive into the code.
First of all, we declare an array that will hold todo items and mark it as an observable
:
@observable
todos = []
What does the @observable
annotation mean? It means that all the changes to the array will be monitored and all the observers
will be notified? What are the observers? Usually, they are React components that reference observables
. And they are re-rendered if corresponding observables
change. We'll have a look at it below.
Now, having declared the data, we need to declare operations that can be performed on it. And, in our case, there are two:
And you can see that they are declared as class methods and have the @action
annotation:
store/store.js
@action
async addTodo(todo) {
await fetch('http://localhost:9999/todos', {
method: 'post',
body: todo
});
this.getTodos();
}
@action
async getTodos() {
const res = await fetch('http://localhost:9999/todos');
const { todos } = await res.json();
this.todos = todos;
}
}
Both addTodo()
and getTodos()
are just regular functions that make HTTP calls and update some data. The only two special things are:
@action
annotationthis.todos
is marked as @observable
.Why does the methods need to be annotated with @action?
First of all, it's a nice convention that clearly marks methods that modify observable data. Secondly, MobX does performance optimisation if observable data is mutated in an action. Finally, MobX has a strict mode that would throw an exception if observables are modified outside of the actions.
Finally, you need to change the root of your application to this:
index.jsx
import React from "react";
import ReactDOM from "react-dom";
import { Provider } from "mobx-react";
import TodoStore from "./store/store";
import App from "./App";
ReactDOM.render(
<React.StrictMode>
<Provider todoStore={new TodoStore()}>
<App />
</Provider>
</React.StrictMode>,
document.getElementById("root")
);
It's almost exactly the same as the one for Redux. The only difference is that we import Provider
from a different module.
observers
Okay, we have re-written the store in MobX. It does look much more concise than the one in Redux. But what about the components? Will they need much re-write?
Luckily, no! Let's examine the Todos
component that is now MobX enabled:
Todos/Todos.jsx
import React, { useEffect } from "react";
import { observer, inject } from "mobx-react";
import "./Todo.css";
const Todos = ({ todoStore }) => {
useEffect(() => {
todoStore.getTodos();
}, [todoStore]);
return (
<div className="list">
{todoStore.todos.map((todo, index) => (
<div key={index} className="todo">
{todo}
</div>
))}
</div>
);
};
export default inject(({ todoStore }) => ({ todoStore }))(observer(Todos));
As you can see the component stayed largely unchanged. Similarly, to the Redux version it receives a property, but this time the property contains a MobX store that have a list of todos. It doesn't need need the mapStateToProps()
. Instead, of connect()
we have inject()
that, as the name suggests, injects the data store into the component.
The most crucial thing that the component is wrapped inside the observer()
function. As mentioned before, components wrapped inside observer()
will be re-rendered once observable
change.
Will all observer
components re-render if any observable
changes?
No! MobX is smart enough only to trigger re-rendering of the components read observables
that get changed. For example, if you have a component that reads from the observable
called todos
, but it the the @observable employees
that gets changed, then your component will not be re-rendered.
Easy!
NewTodo/NewTodo.jsx
import React, { useState } from "react";
import { inject } from "mobx-react";
import "./NewTodo.css";
const NewTodo = ({ todoStore }) => {
const [todo, setTodo] = useState("");
return (
<div>
<input
type="text"
onChange={(e) => setTodo(e.target.value)}
placeholder="New todo item..."
className="new-todo"
/>
<button
onClick={() => todoStore.addTodo(todo)}
className="new-todo-button"
>
Add a new todo
</button>
</div>
);
};
export default inject(({ todoStore }) => ({ todoStore }))(NewTodo);
Once again, it's very similar to its Redux version. And unlike the Todos
component we don't need to wrap it inside observer
. Indeed, NewTodo
doesn't need to be rendered when todos
change. We just need to inject the store with inject()
.
The source code of both the Redux and the MobX version is available on Github. It also includes the API server. So you can all run it.
useEffect()
and useState()
useSelector()
and useDispatch()
?
useObserver()
and useStores()
that you can use instead of observer()
and inject()
.observer()
and inject()
because they make it easier to unit test components. But that could be a matter of taste.observer
PureComponents
The opinions expressed herein are my own personal opinions and do not represent my employer's view in any way. My personal thoughts tend to change, hence the articles in this blog might not provide an accurate reflection of my present standpoint.
© Mike Borozdin