Skip to content

Instantly share code, notes, and snippets.

@elektronik2k5
Created September 22, 2023 11:01
Show Gist options
  • Save elektronik2k5/dc7b029cc8aae23c98a37fd981c82fa6 to your computer and use it in GitHub Desktop.
Save elektronik2k5/dc7b029cc8aae23c98a37fd981c82fa6 to your computer and use it in GitHub Desktop.

Why MobX?

MobX has one job: reactivity

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.

Explicit mutations

We use MobX in a strict configuration, which means that all mutations of observable state must be explicitly marked as actions. 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.

Views and derivations

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!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment