Expressive MVC
API Reference

Model

The base Model class - every method, static, and lifecycle hook

Model is the base class you extend to create reactive state.

import { Model } from '@expressive/mvc';

Members marked (host) exist once a host is loaded - import '@expressive/react' once in your entry, or render with @expressive/dom. Those marked (React) are React-only.


Static methods

Model.new(...args)

Create and activate an instance. Accepts Model.Args - a mixture of initial-value objects, lifecycle callbacks, and nested arrays, all processed in order during activation.

const counter = Counter.new();
const counter = Counter.new({ count: 10 });
const counter = Counter.new({ count: 10 }, (self) => {
  // lifecycle callback
  return () => {
    /* cleanup on destroy */
  };
});

Always use .new() instead of new Counter(). The latter constructs but does not activate - properties aren't managed until activation.

Callbacks can return:

  • () => void - cleanup function, called on destroy.
  • object - merged as initial values.
  • array - flattened and re-processed.
  • Promise - the instance is still created; a rejection is reported to catch handlers with kind setup.

Model.is(maybe)

Type guard. Returns true if maybe is an instance of this class or a subclass.

Counter.is(subCounter); // true if subCounter extends Counter

Model.on(handler)

Register lifecycle handlers, keyed by stage, for this class and its subclasses. Returns a function to remove them. A bare function registers as the setup stage.

const stop = Counter.on({
  type(T) {
    /* once per class, at bootstrap */
  },
  setup() {
    /* per instance, before own values are observed */
    return () => {
      /* per-instance cleanup */
    };
  },
  ready() {
    /* per instance, after the instance's new() */
  },
  catch(error, kind, key) {
    /* an instance failed out of turn */
    return error;
  }
});

Handlers run ancestor-first. A function runs once per stage, however often or on however many classes it is registered.

Activation is a queue: setup handlers, then own fields become managed, then constructor args and the new() hook, then ready handlers, then context registration. A function passed alone is a setup handler. So each phase means:

  • type - runs once when the class first bootstraps, before its members are classified and bound. Receives the class itself, so a handler can inspect or reshape the prototype first. Registered on a base class, it runs for each subclass too.
  • setup - per-instance, before properties are observed and before new(). May return a cleanup, further constructor args, or an object of values to assign.
  • ready - per-instance, after the instance's new() - own values are observed and constructor args applied by then. May return a cleanup.
  • method(key, fn, self) - each time a method is bound to an instance: first read, an assigned replacement, the rebind after a hot patch. fn is the bound function. A handler registered later misses existing bindings. _ methods are never bound, so never reach it. Meant for tooling.
  • catch(error, kind, key?) - see Error handling.

Error handling

When an instance fails out of turn - somewhere no caller could have caught it - the error goes to the catch stage of Model.on() along its class chain. Handlers receive the raw thrown value, a kind, and the key where one applies, with this as the instance.

kindWhen
deada write to a destroyed instance - stored without dispatch
unusedan instance constructed with new but never activated in that tick
gettera computed getter threw while refreshing - its value becomes undefined; key is the getter
setupan async constructor callback, setup handler or new() rejected - the instance is still created
effectan effect, or a function returned by a set() listener, threw during a flush

They behave like nested catch blocks: most-derived class first, last registered first, and a handler registered on several classes runs once, at the outermost. Return the error (or another value) to pass it on; return nothing to handle it; throw to let it escape immediately.

Passed off the end, a destroyed write outputs nothing, unused logs a warning, and anything else escapes uncaught - failing a test run, or crashing a Node process.

Model.on({
  catch(error, kind, key) {
    if (kind == 'unused') return error;
    reportError(error, { state: String(this), kind, key });
  }
});

A getter that throws on its first read throws to the reader instead. Component.catch() is a separate, per-instance boundary for child render errors - see Component.

Model.get(...) (host)

Fetches an instance of this class from context and subscribes the reading component to the properties it accesses. In React, context is what a Component renders or a <Component for> element provides.

function Profile() {
  const { user } = UserProfile.get();
  return <h1>{user.name}</h1>;
}
  • Throws Could not find UserProfile in context. if the instance is not provided above.
  • Re-subscribes (and refreshes) if the upstream instance is replaced or arrives after mount.
  • Outside a render it reads the root context, untracked; through state.get(Type), that instance's context.
  • A React hook: call it unconditionally at the top of a function component or render().

Optional lookup

const maybe = Theme.get(false); // Theme | undefined

Values that may be unset

Hold the value as a Model field - it joins its owner's context. Destructure it and render conditionally; the child reads it with Draft.get():

const { draft } = Settings.get();
if (draft) return <Editor />;
return <Spinner />;

Deriving values

Destructure and derive in render, declare a getter on the Model for a shared derivation, or move side effects to mount().

Overriding get

A subclass may override static get with one signature of its own. It handles its own keys and delegates the base forms with super.get(arg) - ordinary inheritance, so get() and get(false) keep working. For its own key it calls Context.get(this, compute) exactly once, synchronously; compute(context, from?) returns the result, from being the calling instance for session.get(User, 5) and the get instruction.

import { Context, Model } from '@expressive/mvc';

class User extends Model {
  id = 0;

  static get<T extends User>(this: Model.Extends<T>, id?: T['id'] | false): T {
    if (typeof id != 'number') return (id === false ? super.get(false) : super.get()) as T;

    return Context.get(this, (context) => context.get(Directory).find(id) as T);
  }
}

compute should look up the same types whatever the key - branch on what it finds, not on which types to look up. React follows the lookups a component made on its first render.

Type the parameter from the instance (T['id'] | false) so the base forms stay callable - a plain key?: string won't type-check against the base get. Delegate each base form with its own super.get call - a false | undefined argument matches no single base overload.

User.get(5) in a component keeps context and tracking. session.get(User, 5) and the instruction get(User, 5) route through the same override from that instance's context. The instruction resolves once; a thrown Promise suspends the field until it settles, then retries.


Instance methods

get() - export all values

const values = state.get();

Returns a frozen plain object of current values. Exotic values (refs, computed) are unwrapped via their internal .get(). Recursive - exports child instances too. Handles circular references.

get(key, required?) - single property

state.get('count'); // returns the value
state.get('foo', true); // suspends if undefined
state.get('foo', false); // returns undefined without suspense
state.get('method'); // returns the unbound method

For exotic values like ref.Object, returns the unwrapped value. Method keys return the original, unbound function - the counterpart to auto-binding on normal access.

get(effect) - tracked effect

const stop = state.get((current, changed) => {
  console.log(current.count);
  return () => {
    /* cleanup */
  };
});
  • current - tracking proxy. Reads create subscriptions, including reads off an instance fetched with current.get(Type) or current.get(Model).
  • changed - readonly array of keys changed since last run (empty on first run, undefined if the instance wasn't ready).
  • Return a cleanup function, null (cancel), or void.
  • Cleanup receives true (about to re-run), false (cancelled), or null (instance destroyed).
  • Suspense throws inside an effect pause the effect until resolved.

To watch a single property or event instead of tracking reads, use set(event, callback) below.

get(null) / get(null, callback) - destruction

state.get(null); // true if destroyed
state.get(null, () => console.log('destroyed')); // register callback

get(Type, required?) - context lookup

const parent = child.get(Parent); // throws if not found
const maybe = child.get(Parent, false); // undefined if not found
const user = session.get(User, 5); // User.get(5), from session's context

get(Type, callback, downstream?) - subscribe to context availability

state.get(Parent, (parent, downstream) => {
  return () => {
    /* cleanup */
  };
});

Fires immediately if available; otherwise fires when the type becomes available. Searches both directions by default - pass downstream: true to only watch children, false to only watch parents.

get(Model) - owner

const owner = child.get(Model); // throws if it has none
const maybe = child.get(Model, false); // undefined if it has none

const stop = parent.get(Model, (child) => {
  return () => {
    /* child destroyed */
  };
}, true); // each Model this one owns, now and later

Model itself is never in context, so passing it reads ownership instead. The owner is the instance that constructed this one into a field, has() pool or map() - or, failing that, the instance its nearest enclosing context was set up for, such as the Component rendering it. A root, or an instance which was already active when assigned, has no owner.


set() - await pending flush

const updated = await state.set();
// updated: readonly string[] - keys that changed in the batch

Resolves when the current flush completes. Empty if no update is pending. Also activates the instance if it was created with new instead of .new().

set(assign, silent?) - merge values

state.set({ count: 5, name: 'Alice' });
state.set(saved, true); // silent - no events, no report if destroyed

Only known properties and methods are applied. Unknown keys are ignored. is is always ignored. Unmanaged _ fields are assigned too. Silent mode is useful during teardown.

Methods can be replaced:

state.set({
  compute() {
    return this.value + 1;
  }
});

What Assign checks

Model.Assign<T> is Record<string, unknown> intersected with a mapped type over declared fields. The intersection means unknown keys are not rejected at the type level (they're ignored at runtime anyway); the only call-site protection is value-correctness of declared fields:

state.set({ count: 'no' }); // error - count is a number
state.set({ kount: 5 }); // fine by the type-checker - unknown key, ignored at runtime

Self-calls under polymorphic this

Calling this.set({ field }) from inside a subclassable class fails to type-check:

class Base extends Model {
  path = '/';
  go() {
    this.set({ path: '/x' }, true);
    //        ^ Type '{ path: string }' is not assignable to 'Assign<this>'
  }
}

Inside the class body this is the polymorphic this type, so this[K] is unresolved and TypeScript cannot verify the literal's value types - and that value-check is the only thing Assign<this> enforces. Every keyof this-based alternative fails identically (a generic-inferred parameter, Partial<Values<this>>, even the keyed pair set('path', v) - the string literal isn't provably keyof this).

The fix is a cast to the concrete class, which loses no real safety - the value-check it bypasses was never available under polymorphic this:

(this as Base).set({ path: '/x' }, true);

This is a TypeScript limitation (mapped types over polymorphic this), not a bug here. External callers on a concrete instance (base.set({ path })) type-check normally. No looser parameter type can both accept the literal and value-check it while this is unresolved - it's pick-one, so the cast is the idiomatic escape hatch.

set(callback) - listen to all updates

const stop = state.set((key, self) => {
  console.log('updated:', key);
});

Fires synchronously for every property assignment that changes a value, plus explicit dispatches. stop() returns true if the listener was removed, false if it was already gone.

The callback can return:

  • A function - called once when the batch settles. Returning the same function for several events in one tick still runs it once - dedupe is by identity. If it throws, the error is reported to catch handlers with kind effect.
  • null - auto-unsubscribe after this invocation.

set(key) - dispatch an event

state.set('count'); // force an update event without changing value
state.set('my-event'); // custom string event
state.set(Symbol('x')); // symbol event

Useful for signaling internal mutations (e.g. an array pushed) or custom events.

set(null) - destroy

state.set(null);

Destroys the instance. Children are destroyed first, listeners are notified, cleanup runs, and dispatch stops.

Afterward, an assignment is stored without dispatch - the writer reads back what it wrote, so a late async continuation runs to its end. Each such write is reported with kind dead (Error handling); unhandled, it outputs nothing. Repeated late writes mean work outlived its owner - cancel it in a cleanup rather than guarding writes. Subscribing (get(effect), set(callback)) on a destroyed instance still throws.

set(event, callback) - watch a specific key or event

const stop = state.set('count', (key, self) => {
  console.log('count is now', self.count);
});

state.set('my-event', onEvent); // custom events too
state.set(null, () => console.log('destroyed')); // null - destruction

The way to watch a single property. Fires on every assignment that changes the value, and on explicit set(key) dispatches - synchronously, before the flush settles.

Return semantics match set(callback): return a function to run once at settle (deduped), return null to auto-unsubscribe after one invocation, and stop() reports true/false.

set(key, descriptor) - define a property

state.set('foo', { value: 'bar' });
state.set('bar', { value: 'x', set: false }); // read-only
state.set('baz', { value: 'y', enumerable: false }); // non-enumerable
state.set('child', { value: new Child() }); // registers child

Creates or updates a managed property. If it already exists with a reactive getter/setter, only value is accepted.

Descriptor fields:

  • value - initial value.
  • get - custom getter, true (required/suspense), or false (optional).
  • set - custom setter function or false (read-only).
  • enumerable - default true.

Instance properties

.is

Non-enumerable self-reference. Two purposes:

  1. Write access after destructuring - const { is: counter, count } = state; counter.count = 5;
  2. Silent reads inside tracking contexts - current.is.value reads without subscribing.

state.is === state always, likewise state.is.is === state.is.

Unmanaged fields

A field whose name starts with _ is not managed: writes never notify and never report after destroy. It is non-enumerable, so it is left out of get(), iteration, ref(this) and Model.Values, but overlays - constructor args, set({ ... }) and Component props - still assign it. A _ getter is never computed and never subscribes. Details in unmanaged properties.


Lifecycle hooks

These are not on the Model prototype. Define them on your class to opt in.

new()

class Cart extends Model {
  items: string[] = [];
  updatedAt = 0;

  protected new() {
    return this.set('items', () => {
      this.updatedAt = Date.now();
    });
  }
}

Runs once after activation, wherever the instance is created - including during server render. Return a cleanup function to run on destruction, or void. new?() is declared as an optional protected member of Model, so its signature is type-checked and override catches a misspelling. If it returns a promise that rejects, the rejection is reported with kind setup. Setup that must accompany the instance itself goes here; client-only resources (DOM, timers, subscriptions) belong in mount().

mount() (host)

class Ticker extends Model {
  elapsed = 0;

  mount() {
    const id = setInterval(() => this.elapsed++, 1000);
    return () => clearInterval(id);
  }
}

Runs once when the component that owns the instance commits - a Component rendered as JSX, or a <Component for={StateClass}> that created it. Return a function to run on unmount.

Never runs during server render, and never for an instance merely observed or placed - .get() and {instance} reach an instance the component does not own, and Model.new() has no component at all. Client-only effects (DOM, timers, subscriptions) belong here; setup that must accompany the instance itself belongs in new().


Iteration

for (const [key, value] of state) {
  // yields managed properties
}

Types

See Types for Model.Extends, Model.Type, Model.Field, Model.Args, Model.On, Model.Values, and friends.

On this page