Tech & Trends
12. 05. 2020
Why you should use MobX State Tree in your next React project
At Lloyds we write a lot of React and React Native apps. All apps require some state management, so we naturally had to choose a state management library to use in our projects. After some consideration and testing, some back and forth with Redux, MobX and some other solutions, we decided to try MobX State Tree. We loved the features, simplicity and developer experience so much! We just had to share it, so we decided to write this article.
MobX
MobX is awesome! It’s simple, performant and easy to learn.
We define our observable state and simply access that state in components. Whenever our state changes, our component re-renders automatically.
We can define the simplest counter app like this:
const state = observable({
count: 0
});
const CountView = observer(() => {
return (
{state.count}
state.count++} title="CLICK ME" />
);
});
We use the state just like a regular JavaScript object. MobX tracks the properties we access on the state and re-renders our component whenever those properties change. Notice that we marked the state to be observable and wrapped our component in the observer HOC that enables MobX to track properties and re-render our component.
Let’s consider an example that’s a bit more complex. We can create a simple to-do app.
const state = observable({
todoList: ["Buy milk"]
});
const actions = {
addTodo(todo) {
state.todoList.push(todo);
},
removeTodo(todo) {
state.todoList.remove(todo);
}
};
const TodoApp = observer(() => {
const [todo, setTodo] = useState("");
return (
<View style={S.container}>
<TextInput value={todo} onChangeText={setTodo} placeholder="I have to..." />
<Button
title="ADD"
onPress={() => {
actions.addTodo(todo);
setTodo("");
}}
/>
<Text>Todos:</Text>
{state.todoList.map(todo => (
<View style={S.row}>
<Text>{todo}</Text>
<Button title="X" onPress={() => actions.removeTodo(todo)} />
</View>
))}
</View>
);
});
Yes, MobX really is great, but as the application grows, so does the application state so naturally you start breaking stuff up into multiple stores and communication between different parts of the app starts getting complicated.
MobX State Tree
MobX gives us a lot out of the box, but we can get a whole lot more by using MobX State Tree. MST is a state management library built on top of MobX. It’s stricter than MobX, but we get some additional features when we use it. We keep our state in a tree of models and we can freely navigate up and down the tree structure.
Additionally, we get some nice features that make for a delightful developer experience.
Notice that, in the pure MobX implementation, we have the action addTodo that accepts one parameter and pushes that in the todoList.
addTodo(todo) {
state.todoList.push(todo);
},
We always pass a string as the parameter to addTodo, but we could easily pass some invalid data to addTodo by accident. In a large app, it’s not uncommon to deal with the same state from multiple parts of the app, and it’s easy to mistake addTodo(todo) with, for example, addTodo({ todo }).
If we push an object in the todoList array, the app won’t throw an error in our data layer. If we’re lucky, we’ll get an error from the view layer when React tries to render an object as a child of a text component, but we’ll see that error only when we actually render the todos.
If we’re not lucky, the buggy todo will stay in the array, waiting to crash some app for some unsuspecting user.
It would be nice if we could get an error as soon as we try pushing invalid data in the todoList array. That’s where MST data validation comes in.
Let’s rewrite the above todo app example with MST and see how it works.
const Store = types
.model("Store", {
todoList: types.array(types.string)
})
.actions(self => {
return {
addTodo(todo) {
self.todoList.push(todo);
},
removeTodo(todo) {
self.todoList.remove(todo);
}
};
});
const store = Store.create({
todoList: ["Buy milk"]
});
const TodoApp = observer(() => {
const [todo, setTodo] = useState("");
return (
<View>
<TextInput
value={todo}
onChangeText={setTodo}
placeholder="I have to..."
/>
<Button
title="ADD"
onPress={() => {
store.addTodo(todo);
setTodo("");
}}
/>
<Text>Todos:</Text>
{store.todoList.map(todo => (
<View style={S.row}>
<Text>{todo}</Text>
<Button title="X" onPress={() => store.removeTodo(todo)} />
</View>
))}
</View>
);
});
First thing we do is define the data model. You can think of models as schemes that define the shape and types of your data. Additionally, we can add actions that modify the data. That way we keep the data and the actions that modify that data in a single location. This concept is known as encapsulation.
In our example we create a Store model where we keep the array of todos and actions for adding and removing todos from the array. We expect the todos themselves to be strings so we define them as such using types.array(types.string).
const Store = types
.model("Store", {
todoList: types.array(types.string)
})
.actions(self => {
return {
addTodo(todo) {
self.todoList.push(todo);
},
removeTodo(todo) {
self.todoList.remove(todo);
}
};
});
Then we create an instance of the model using Store.create() and pass the initial state as the first argument.
When instantiating models, MST will validate the data and throw developer friendly errors if the data doesn’t match the defined schema. For example, if we tried passing { todoList: [ {“todo”:”Buy Milk”} ] } as the initial state, we would get the following error.
[mobx-state-tree] Error while converting `{"todoList":[{"todo":"Buy Milk"}]}` to `Store`:
at path "/todoList/0" snapshot `{"todo":"Buy Milk"}` is not assignable to type: `string` (Value is not a string).
This helps us catch and fix bugs early and follows the principles of defensive programming.
CodeSandbox:
https://codesandbox.io/s/mst-todo-app-dhj3r
Data validation is not the only great feature MST has to offer. Another cool feature are references.
References
References offer a way to – you guessed it – reference model instances in a safe and simple way. In order to use references we first have to define identifiers on our models. So let’s extend our todo app to see how this works.
First we’ll create a Todo model and add an autogenerated identifier prop.
const Todo = types
.model("Todo", {
id: types.optional(types.identifier, () => Math.random().toString()),
text: types.string
})
We generate a random id every time a new todo is created.
Next we’ll modify the Store model by changing the todoList prop to be an array of our newly defined Todo models.
We’ll also add the selectedTodo prop and set its type to be a safeReference to a Todo, and add an action to set the selected todo.
First we’ll create a Todo model and add an autogenerated identifier prop.
const Store = types
.model("Store", {
todoList: types.array(Todo),
selectedTodo: types.safeReference(Todo)
})
.actions(self => {
return {
/* ... */
selectTodo(todo) {
self.selectedTodo = todo.id;
}
};
});
So, the way references work is like this – when setting a reference, we provide an identifier of an existing model instance. On the other side, when we access the reference, MST will automatically resolve the model instance and return it. If we delete the selected todo it will get removed from the tree and the reference will be set to undefined.
We change the component to highlight the selected todo with green background.
/* ... */
<Text>Todos:</Text>
{state.todoList.map(todo => {
const selected = todo === state.selectedTodo;
const backgroundColor = selected ? "#8f8" : "#fff";
return (
<TouchableOpacity
style={[S.todoWrap, { backgroundColor }]}
onPress={() => state.selectTodo(todo)}
>
<Text style={S.todoText}>{todo.text}</Text>
<Button title="X" onPress={() => state.removeTodo(todo)} />
</TouchableOpacity>
);
})}
/* ... */
Note that state.selectedTodo is the actual todo instance (with id and text properties and all actions defined on the Todo model).
CodeSandbox:
https://codesandbox.io/s/mst-todo-app-with-references-1xel4
Async Actions
When using MST it’s recommended to write async actions using the flow helper and generator functions. Generators can be a bit overwhelming for new users, but using generators in MST is really simpler than it seems. Here’s how you can do a simple API call.
.actions(self => {
return {
getTodos: flow(function*() {
self.loading = true;
const response = yield getEnv(self).http.get("/todos");
self.loading = false;
self.todoList = response.data;
})
}
})
Flows with generators are similar to async/await. You just replace await with yield and async function with function *. This enables MST to batch UI updates. For example, if we were to use async/await to fetch the todos, the UI would be updated twice – once for self.loading = false and a second time for self.todoList = response.data. When using generators, MST can wait until the async action is over or yields and only then re-render the UI which improves app performance.
If you’re interested in learning more, there’s a lot more features in MST described on the official site.
Architecture
So far we’ve introduced some core MST features that we love. Even though all the features we talked about are great, it still took time until we came up with a way to structure the stores and define a directory structure that we use today.
We strive to reduce data redundancy (avoid same data defined in multiple places). We want to have a single source of truth at all times. The next section of this article explains how we used MST to achieve this goal.
Data Normalization
Data normalization is the process of structuring data in such a way to reduce data redundancy and improve data integrity.
Let’s say that we have an API endpoint /books that returns a list of book entities with a nested author entity.
> GET /books
< [
< {
< "id": "f3e6c707",
< "title": "title 0",
< "author": {
< "id": "c232ecf0",
< "name": "Jane Austen"
< }
< },
< {
< "id": "71f78b33",
< "title": "title 1",
< "author": {
< "id": "4dba331c",
< "name": "William Blake"
< }
< },
< /* ... */
< ]
We could store that data in the format we receive it from the API – with the author entity nested inside, but what if we fetch the list of authors on a different place in the app? We would have two copies of a single author in memory – one nested in a book on the book list, and another on the author list.
What we instead want is to normalize the data. We can make the author property on the book entity a reference to the author entity, and keep the actual author data in a separate collection.
First we create two models for each entity – one for the entity itself, and one for store that keeps a collection of the entities and actions for CRUD operations on the entity itself. Additionally, the entity store has an action for processing entities that normalizes the data and recursively calls other actions to process nested entities.
export const AuthorStore = types
.model("AuthorStore", {
map: types.map(Author)
})
.actions(self => {
return {
// we use this to add authors to the collection
processAuthorList(authorList) {
for (const author of _.castArray(authorList)) {
self.map.put(author);
}
}
};
})
.actions(self => {
return {
createAuthor: flow(function*(params) {
const env = getEnv(self);
const response = yield env.http.post(`/authors`, params);
self.processAuthorList(response.data);
return response;
}),
readAuthorList: /* GET /authors */,
readAuthor: /* GET /authors/:id */,
updateAuthor: /* POST /authors/:id */,
deleteAuthor: /* DELETE /authors/:id */
};
});
The BookStore model is similar except we normalize the nested Author entity
export const BookStore = types
.model("BookStore", {
map: types.map(Book)
})
.actions(self => {
return {
// here we add books to the collection
// and normalize the nested author entity
processBookList(bookList) {
const { processAuthorList } = getRoot(self).authorStore;
for (const book of _.castArray(bookList)) {
if (book.author) {
processAuthorList(book.author);
entity.author = book.author.id;
}
self.map.put(entity);
}
}
};
})
.actions(self => {
return {
/* API CRUD operations */
};
});
This approach makes our component code simple and clear. Keeping the data normalized reduces bugs when creating, updating and deleting entities. You can see it all together in the sandbox:
https://codesandbox.io/s/mst-example-vwmr9
Conclusion
MobX State Tree enables us to write simple, maintainable and highly performant code. Features like data validation and references provide a great developer experience and enable us to easily implement a data normalization layer in our applications. This architecture helps us to write higher quality code with less bugs that’s easier to maintain and reason about.
We can’t recommend MobX State Tree highly enough.
You can read more about it here: https://mobx-state-tree.js.org/intro/philosophy