We use MobX to describe and keep our application domain data, mutate it in a typesafe manner and have the UI and side effects react to those mutations in a controlled way. MobX has one job: change detection and automatic reactions to those changes. It doesn't tell us how to structure our code, which abstractions to use, whether to use classes or POJOs or how to organize files.
And it is always synchronous, which is important because you don't ever need any inspection tools or debugging addons other than debugger
to see exactly what's going on. The abstraction is very thin and any change can be traced directly back to the code which triggered it simply by following the stack trace. How you choose to write and structure code is 100% up to you.
MobX isn't tied to any specific platform and only requires a JS runtime. All our models should be free of any platform specific code or dependencies, such as DOM, Node, RN, React, etc. We pass our models to platform specific code for reading and mutating (via actions
- covered in the next section). This allows us to share the domain logic across platforms and run tests independent of platform specific bindings.
This separation naturally enforces a clear boundary between the domain state/logic and execution environment.
If you're new to MobX, please read the entire documentation here. It isn't long.
We use MobX in a strict configuration, which means that all mutations of observable state must be explicitly marked as action
s. The reason we opt into this behavior is because we want to be in full control of who/when/where mutates domain data. It may seem arbitrary at first, but is the right thing to do in terms of maintainability in a non trivial and data centric app. Think of it as equivalent to TypeScript's explicit type annotations, which similarly feel redundant and arbitrary at first but then save you tons of time when refactoring confidently.
We annotate class methods (usually) in the constructor by calling makeAutoObservable(this, { actionName: action, anotherActionName: action })
. Failing to mark a function/method which includes a mutation as an action
will trigger a console warning.
class UserModel {
firstName = '';
// Explicit mutation
setFirstName = (firstName: typeof this.firstName): void => {
this.firstName = firstName;
};
lastName = '';
// Note the inferred type of argument and return type
setLastName = (lastName: typeof this.lastName): void => {
this.lastName = lastName;
};
constructor({ firstName, lastName }: UserModelProps) {
// Annotating mutations with `action` makes them explicit
makeAutoObservable(this, { setFirstName: action, setLastName: action });
this.setFirstName(firstName);
this.setLastName(lastName);
}
}
Note that because MobX isn't opinionated, there's more than one way to do it. But we always want to use explicit actions and have them defined in our models. That's because we don't want domain logic to creep into the UI or side effects.
Sometimes we need to have properties which are based and depend on the values of other properties. For example, if we need a fullName
prop, which concatenates firstName
+ space + lastName
, we may try this:
class UserModel {
firstName = ''
lastName = ''
+ fullName = ''
- setFirstName = (firstName: typeof this.firstName): void => { this.firstName = firstName }
+ setFirstName = (firstName: typeof this.firstName): void => {
+ Object.assign(this, { firstName, fullName: `${firstName} ${this.lastName}` })
+ }
- setLastName = (lastName: typeof this.lastName): void => { this.lastName = lastName }
+ setLastName = (lastName: typeof this.lastName): void => {
+ Object.assign(this, { lastName, fullName: `${this.firstName} ${lastName}` })
+ }
}
But duplicating data requires synchronization, risks staleness and conflicts if one forgets the required bookkeeping - especially with code branching and async updates. In addition, you may encounter race conditions and a lack of a single source of truth. There's a better way.
We must define the minimal amount of model data, which cannot be reduced any further as the single source of truth and derive different views from that "canonical data". When the data changes, the derived and reactive views will change too:
class CustomerModel {
firstName = '';
lastName = '';
isActive = false;
email = '';
// Views which react when properties change
get fullName(): string {
return `${this.firstName} ${this.lastName}`;
}
// Views can contain any logic you want and be as complex as necessary
get hasValidName(): boolean {
return this.firstName.trim() !== '' && this.lastName.trim() !== '';
}
get hasValidEmail(): boolean {
return /some impossible regex here/.test(this.email.trim());
}
// Views can depend on other views
get isValidUser(): boolean {
return this.hasValidName && this.hasValidEmail;
}
get canMakePurchase(): boolean {
return this.isActive && this.isValidUser;
}
}
You can read all these properties without any additional ceremonies: console.info(customerInstance.canMakePurchase) // Logs false
. These derivations are performed synchronously, so in case of an exception, we get a meaningful error and a readable stack trace.
The above example works fine without MobX. The part which MobX adds is reactivity: any property/view which is read anywhere in the code will automatically be updated when the data changes - without any additional code!