MobX provides a simple and powerful approach to managing client side state. It uses a technique called Transparent Functional Reactive Programming (TFRP) wherein it automatically computes a derived value if any of the dependent values change. This is done by setting up a dependency graph that tracks the value changes.
MobX causes a shift in mindset (for the better) and changes your mental model around managing client side state.
After having used it for more than 6+ months on multiple React projects, I find certain patterns of usage recurring very frequently. This series of posts is a compilation of various techniques I’ve been using to manage client state with MobX.
This is going to be a 3-part series. In this first part we will look at shaping the MobX State Tree.
This is the part where you sculpt the shape of your Store.
Modeling the client-state is probably the first step when starting with MobX. This is most likely a direct reflection of your domain-model that is being rendered on the client. Now, when I say client-state, I am really talking about the ”Store”, a concept you may be familiar with if you are coming from a Redux background. Although you only have one Store, it is internally composed of many sub-Stores that handle the various features of your application.
The easiest way to get started is to annotate properties of your Store
that will keep changing as @observable
. Note that I am using the decorator syntax but the same can be achieved with simple ES5 function wrappers.
import { observable } from 'mobx';
class AlbumStore {
@observable
name;
@observable
createdDate;
@observable
description;
@observable
author;
@observable
photos = [];
}
By marking an object as @observable
, you automatically observe all of its nested properties. Now this may be something you want but many a time its better to limit the observability. You can do that with a few MobX modifiers:
asReference()
This is useful when there are certain properties that will never change value. Note that if you do change the reference itself, it will fire a change.
let address = new Address();
let contact = observable({
person: new Person(),
address: asReference(address),
});
address.city = 'New York'; // No notifications will be fired
// Notifications will be fired as this is a reference change
contact.address = new Address();
In the above example, the address
property will not be observable. If you change the address details, you will not be notified. However if you change the address reference itself, you will be notified.
An interesting tidbit is that propeties of an observable object whose values have a prototype (class instances) will automatically be annotated with asReference()
. Additionally these properties will not be recursed further.
asFlat
This is slightly more loose than asReference. asFlat
allows the property itself to be observable but not any of its children. The typical usage is for arrays where you only want to observe the array instance but not its items. Note that in case of arrays, the length
property is still observable as it is on the array instance. However any changes to the children’s properties will not be observable.
Tip Start off by making everything @observable
and then apply the asReference
and asFlat
modifiers to prune the observability.
This kind of pruning is something you discover as you go deeper into implementing the various features of your app. It may not be obvious when you start out, and that is perfectly OK! Just make sure to revisit your Store as and when you recognize properties that don’t need deep observability. It can have a positive impact on your app’s performance.
import { observable } from 'mobx';
class AlbumStore {
@observable
name;
// No need to observe here
@observable
createdDate = asReference(new Date());
@observable
description;
@observable
author;
// Only observing the photos array, not the individual photos
@observable
photos = asFlat([]);
}
This is the symmetric opposite of pruning the observables. Instead of removing observability you can expand the scope/behavior of observability on the object. Here you have three modifiers that can control this:
asStructure
This modifies the way equality checks are done when a new value is assigned to an observable. By default only reference changes are considered as a change. If you prefer to compare based on an internal structure, you can use this modifier. This is essentially for value-types (aka structs) that are equal only if their values match.
const { asStructure, observable } = require('mobx');
let address1 = {
zip: 12345,
city: 'New York',
};
let address2 = {
zip: 12345,
city: 'New York',
};
let contact = {
address: observable(address1),
};
// Will be considered as a change, since its a new reference
contact.address = address2;
// With asStructure() annotation
let contact2 = {
address: observable(asStructure(address1)),
};
// Will NOT be considered as a change, since its the same value
contact.address = address2;
asMap
By default when you mark an object as observable, it can only track the properties initially defined on the object. If you add new properties, those are not tracked. With asMap, you can make even the newly added properties observable. Internally, MobX will create a ES6-like Map that has a similar API like the native Map
.
Instead of using this modifier, you can also achieve a similar effect by starting with a regular observable object. You can then add more observable properties using the extendObservable()
API. This API is useful when you want to lazily add observable properties.
computed
This is such a powerful concept that its importance cannot be emphasized enough. A computed
property is not a real property of your domain, rather it is derived (aka computed) using real properties. A classic example is the fullName property of a person instance. It is derived from the firstName and lastName properties. By creating simple computed properties, you can simplify your domain logic. For example, instead of checking if a person has a lastName everywhere, you could just create a computed hasLastName property:
class Person {
@observable
firstName;
@observable
lastName;
@computed
get fullName() {
return `${this.firstName}, ${this.lastName}`;
}
@computed
get hasLastName() {
return !!this.lastName;
}
}
Shaping the observable tree is an essential aspect of using MobX. This sets up MobX to start tracking the parts of your Store that are interesting and change-worthy!
In Part 2 we look at how you can take @action
when your observables change. These are the side effects of your application!
Huge thanks to Michel Westrate (author of MobX) for reviewing the content.