Skip to content

Latest commit

Β 

History

History
324 lines (246 loc) Β· 12.4 KB

File metadata and controls

324 lines (246 loc) Β· 12.4 KB

MobX Package

Overview

The /mobx/ package is Hoist's integration layer with MobX. It serves three purposes:

  1. Re-exports core MobX and mobx-react-lite APIs from a single Hoist import path
  2. Configures MobX with enforceActions: 'observed' for the entire application
  3. Provides the @bindable decorator for Hoist's model system
/mobx/
β”œβ”€β”€ index.ts        # Re-exports + MobX configuration
└── decorators.ts   # @bindable decorator (TC39 accessor decorator)

All Hoist code imports MobX APIs from @xh/hoist/mobx rather than directly from mobx. This ensures the enforceActions configuration is applied and @bindable support is available.

Re-exported APIs

The following are re-exported from MobX and mobx-react-lite:

Export Source Purpose
observable mobx Mark properties as reactive state
computed mobx Derive cached values from observables
action mobx Mark methods that modify observable state
runInAction mobx Execute a block of code as an action
autorun mobx Run a side-effect whenever observed values change
reaction mobx Run a side-effect when a specific data expression changes
when mobx Run a side-effect once when a condition becomes true
toJS mobx Convert observable data to plain JavaScript
extendObservable mobx Add observable properties to an existing object
untracked mobx Read observables without creating a dependency
compareStructural, compareShallow, compareIdentity, compareDefault mobx Built-in equality comparers for computed values and reactions
isObservableProp mobx Check if a property is observable
observer mobx-react-lite HOC that makes React components reactive to observable changes

Action Enforcement

The package configures MobX with enforceActions: 'observed', meaning any modification to an observable property that is currently being observed must occur inside an @action method, runInAction() block, @bindable setter, or a reaction/when run callback (MobX auto-wraps these in an action). MobX logs a warning if this rule is violated.

This enforcement prevents accidental state mutations and makes data flow predictable.

@bindable Decorator

The @bindable decorator creates an observable accessor field with an automatically generated setter method:

import {HoistModel} from '@xh/hoist/core';
import {bindable} from '@xh/hoist/mobx';

class PortfolioModel extends HoistModel {
    @bindable accessor selectedFund: string = null;
    @bindable accessor showInactive: boolean = false;
}

This creates:

  • Observable properties selectedFund and showInactive
  • Setter methods setSelectedFund(value) and setShowInactive(value) (MobX actions)

The accessor keyword is required β€” it transforms the field into a getter/setter pair that TC39 decorators can intercept to add reactivity.

@bindable vs @observable

Both decorators make properties reactive. Choose based on how the property is modified:

Use @bindable when a property is intended to be set freely from outside the model β€” the generated setter is a MobX action, so external code can safely assign values without wrapping in runInAction:

// βœ… Direct assignment works β€” @bindable's setter is an action
model.selectedFund = 'Growth';

// Also works β€” but prefer direct assignment for auto-generated setters
model.setSelectedFund('Growth');

Use @observable when the model manages the property internally and is not expected to be changed from outside, or when the model provides custom @action setters that include additional logic (validation, side-effects, coordinated updates):

class PortfolioModel extends HoistModel {
    // Internal state β€” updated only by the model's own methods
    @observableRef accessor records: StoreRecord[] = [];

    // Custom setter with coordinated side-effects
    @observableRef accessor activeFilter: Filter = null;

    @action
    setActiveFilter(filter: Filter) {
        this.activeFilter = filter;
        this.loadDataAsync();       // trigger reload as part of the update
    }

    @action
    updateRecords(data) {
        this.records = this.store.processedRecords;
    }
}

@bindableRef

Use @bindableRef for properties where only reference changes should trigger reactions β€” not deep mutations within the value. This uses observableRef semantics under the hood:

@bindableRef accessor selectedRecord: StoreRecord = null;  // track reference only
@bindableRef accessor dimensions: {width: number, height: number} = null;

Setter Convention

If a class defines an explicit setFoo() method, call it β€” it likely contains additional logic (validation, side-effects). For auto-generated @bindable setters, prefer direct assignment:

// βœ… Do: direct assignment for auto-generated setters
model.showInactive = true;

// βœ… Do: call explicit setter when one is defined (it has extra logic)
model.setFilter(newFilter);   // e.g. triggers reload

@computed Decorator

The @computed decorator marks a getter as a cached derived value. MobX tracks which observables the getter reads and only re-evaluates it when those dependencies change. More importantly, @computed only notifies downstream observers when the getter's output changes β€” not merely when its inputs change. This output-gating behavior is the primary optimization @computed provides and the main reason to use it.

Reducing Re-renders

Consider a form model that aggregates dirty state across many fields:

@computed
get isDirty(): boolean {
    return some(this.fields, f => f.isDirty);
}

Without @computed, every component that reads isDirty would re-render whenever any field's dirty state changes β€” even if the overall result stays true. With @computed, MobX re-runs the getter when a field changes, but only triggers re-renders if the boolean actually flips. The computed acts as a firewall in the dependency graph, collapsing many fine-grained observable changes into a single stable output.

This is especially valuable for:

  • Boolean aggregations β€” combining many observable flags into one (isDirty, isValid, canEdit). The output is a single boolean that changes far less often than any individual input.
  • Filtered collections β€” deriving a subset from an observable list. When used with @computedStruct, the output only changes when the actual contents change.
  • Multi-observable conditions β€” combining several observables into a derived state that downstream components and reactions depend on.
class TaskListModel extends HoistModel {
    @observableRef accessor tasks: Task[] = [];
    @observable accessor filterText: string = '';
    @observable accessor showCompleted: boolean = false;

    // βœ… Good: combines three observables. Components that only care about
    // the filtered result won't re-render when an irrelevant filter changes.
    @computed
    get visibleTasks(): Task[] {
        let ret = this.tasks;
        if (this.filterText) {
            ret = ret.filter(t => t.name.includes(this.filterText));
        }
        if (!this.showCompleted) {
            ret = ret.filter(t => !t.completed);
        }
        return ret;
    }

    // βœ… Good: boolean derived from the computed above β€” changes far less
    // often than the underlying task list.
    @computed
    get hasVisibleTasks(): boolean {
        return !isEmpty(this.visibleTasks);
    }
}

@computedStruct

By default, @computed uses reference equality (===) to compare old and new results. For getters that return new object or array instances on each evaluation, use @computedStruct to compare by structural equality instead:

@computedStruct
get persistableColumnState(): ColumnState[] {
    return this.cleanColumnState(this.columnState);
}

This prevents reactions from firing when the getter produces a structurally identical result even though the reference is new. Use sparingly β€” structural comparison has its own cost.

When to Use Plain Getters Instead

Not every getter needs @computed. Plain getters are re-evaluated on every access but add no MobX tracking overhead. Prefer plain getters when the output always changes whenever the input changes (see Common Pitfalls below) or when the derivation is trivial and accessed from a single reactive context.

Common Pitfalls

Using @observable (deep) for non-primitives

MobX's default @observable applies deep observation, recursively wrapping nested properties in proxies. This is rarely what you want for arrays, objects, or class instances. Use the Ref variant instead:

// ❌ Don't: deep observation wraps the array and its contents in proxies
@observable accessor items: Item[] = [];

// βœ… Do: ref observation tracks only the reference β€” no proxy wrapping
@observableRef accessor items: Item[] = [];

// βœ… Primitives are fine with plain @observable β€” no proxies involved
@observable accessor isOpen: boolean = false;
@observable accessor count: number = 0;
@observable accessor label: string = '';

The same applies to @bindable vs @bindableRef β€” use @bindableRef for non-primitives.

In hoist-react, bare @observable is used only for primitives (booleans, strings, numbers, enums). Everything else β€” arrays, objects, class instances β€” uses @observableRef or @bindableRef.

Mutating Ref values in place

When using Ref variants, MobX only tracks reference changes to the property, not mutations within the value. To trigger reactions, you must replace the entire value with a new instance:

@observableRef accessor filters: Filter[] = [];

// ❌ Don't: push mutates the existing array β€” MobX won't detect the change
@action addFilter(f: Filter) {
    this.filters.push(f);
}

// βœ… Do: spread into a new array to create a new reference
@action addFilter(f: Filter) {
    this.filters = [...this.filters, f];
}

// βœ… Do: same pattern for objects β€” spread into a new object
@observableRef accessor settings: {theme: string, compact: boolean} = {theme: 'dark', compact: false};

@action updateTheme(theme: string) {
    this.settings = {...this.settings, theme};
}

Accessor fields are non-enumerable

TC39 accessor fields become getter/setter pairs on the class prototype. Unlike plain class fields, they do not appear in Object.keys(), JSON.stringify(), {...spread}, or for...in loops. Code that iterates model properties to discover observable state must use MobX APIs (e.g. getObservableKeys()) instead of Object.keys().

Unnecessary @computed on simple transformations

Adding @computed to a getter that performs a 1:1 transformation of a single observable provides no optimization if the output always changes when the input changes. The decorator still re-runs the getter and compares the result, but since the result is always different, the comparison is wasted work and the output-gating benefit never applies:

// ❌ Don't: output always changes when `count` changes β€” no gating benefit
@computed
get countLabel(): string {
    return `${this.count} items`;
}

// ❌ Don't: formatted string always changes when the date changes
@computed
get formattedDate(): string {
    return fmtDate(this.selectedDate);
}

// βœ… Do: plain getter β€” simple and honest about re-evaluation
get countLabel(): string {
    return `${this.count} items`;
}

This pattern typically indicates a misunderstanding of what @computed does β€” treating it as a general-purpose caching mechanism (like useMemo) rather than an output-change gate that prevents unnecessary downstream reactions. Reserve @computed for getters where the output genuinely changes less often than its inputs.

Importing from mobx directly

Always import from @xh/hoist/mobx. Importing directly from mobx bypasses Hoist's enforceActions configuration and @bindable support.

Related Packages

  • /core/ β€” HoistModel (where @computed getters and observable state are defined), @managed decorator, addAutorun()/addReaction() managed reactive subscriptions
  • /promise/ β€” thenAction() for modifying observables in promise chains
  • /utils/js/ β€” Additional decorators (@debounced, @computeOnce, @logWithInfo)