Skip to content

Model Kinds ​

model and staticModel declare fields, traits, and relations the same way, live in the same collections, and expose the same instance surface. They differ in one thing: when the units from setup are created. This page is about that difference, and about when neither is the right tool and a plain core owner is.

staticModel: Units Once ​

A static model builds its reactive graph a single time. An instance is a lightweight scope over the shared units - creating one writes field values, nothing else:

ts
const Todo = staticModel({
  data: { done: f.boolean(false) },
  setup(self) {
    const toggled = event<void>();

    reaction({
      on: toggled,
      run: () => {
        self.done.value = !self.done.value;
      },
    });

    return { toggled };
  },
});

setup runs once, at declaration. Every reaction still behaves per instance - await a.toggled() flips only a - and the instance API on self (self.id, self.json()) is available inside unit bodies, where a concrete instance exists. Disposed instance scopes are pooled and reused.

Per-instance external resources still have a home: self.onCleanup from a unit body ties an unsubscribe to the ambient instance - no store, no per-instance units.

This is the default kind: a thousand list rows, feed entries, or table cells per frame is a normal load.

model: Units Per Instance ​

A dynamic model runs setup for every instance, and setup receives the creation input as its second argument:

ts
const ReportScreen = model({
  data: {
    period: f.string("month"),
    rows: f.array(f.number(), []).local(),
  },
  setup(self, props) {
    // this closure belongs to ONE instance - create whatever it needs
    const refresh = event<void>();
    const total = computed(() => self.rows.value.reduce((a, b) => a + b, 0));

    reaction({
      on: refresh,
      run: async () => {
        self.rows.value = await api.report(self.period.value, props.filters);
      },
    });

    return { refresh, total };
  },
});

const report = collection(ReportScreen).add({ period: "week", filters: { team: 1 } });

await report.refresh();
report.total.value;

What happens:

  • stores, events, computeds, and reactions are created for this instance and owned by it - dispose() tears them all down;
  • props is the raw add input - keys that are not fields (like filters above) are visible to setup even though they are not stored;
  • because each instance owns real units, setup may branch: create different reactions per instance, capture per-instance closures, hold resources.

The price is real allocations per instance. Use model for few, individual entities: screens, editors, wizards, long-lived workspaces.

Choosing Between Them ​

staticModelmodel
Unitsone shared graphcreated in setup per instance
Instance costa scope and a value mapstores + reactions + an owner
setup runsonce, at declarationon every add
setup arguments(self)(self, props) - the creation input
Per-instance closuresno - state lives in fieldsyes
Poolingdisposed scopes are reusedno - disposal is a real teardown
Typical counthundreds to thousandsa handful

Everything else - fields, traits, relations, queries, serialization, keep in bindings - is identical. Start with staticModel; switch to model when an instance genuinely needs its own dynamically created units.

And Plain owner? ​

The core owner is the primitive underneath: a disposable tree of units and resources. A dynamic model instance is an owner inside - model adds the entity layer on top:

  • a declaration: fields that validate input and serialize back;
  • a home: the collection, with get-or-create, id lookup, and merge semantics;
  • relations with delete policies, queries with indexes, rebind and aliases.

Rule of thumb:

  • a domain entity - something with data, an id, a place in lists - is a model or staticModel;
  • an app subsystem - a socket manager, a player, a cache, a background process - is an owner: it has resources and lifetime, but no wire shape, no id, and nothing to query;
  • if you find yourself giving an owner an id registry and a serializer, it wanted to be a model.

Contract ​

ts
model({
  with?, data?, name?,
  setup?(self, props): members | void,  // per instance; props is the add input
});

staticModel({
  with?, data?, name?,
  setup?(self): members | void,         // once; instance API in unit bodies only
});

Both return a definition usable with collection, traits, relations, unions, and bindings interchangeably.

Common Cases ​

  • List rows, feed items, table cells - staticModel.
  • Screens and editors with per-instance wiring - model, often with keep.
  • Domain entities shared by both worlds - either kind; the surface is the same, so switching later is a one-word change.
  • Non-entity subsystems - core owner, not a model at all.