Expressive MVC
API Reference

Utilities

Framework-agnostic helpers - pending, Context, unbind, and the observable protocol

Everything on this page comes from @expressive/mvc - the core package under every adapter. Import it from there, by name; the import paths matter here.


Where things live

The core package has a main entry and a few subpaths:

// main entry - Model and friends
import {
  Model,
  Component,
  Context,
  def,
  get,
  has,
  map,
  pending,
  ref,
  set,
  unbind
} from '@expressive/mvc';

// the observable protocol - a separate subpath
import {
  event,
  listener,
  observer,
  Observer,
  touch,
  watch
} from '@expressive/mvc/observable';

// class hot reload - for build integrations
import { accept, replaced } from '@expressive/mvc/hot';

@expressive/mvc/jsx-runtime and /jsx-dev-runtime also exist, for the JSX transform - you don't import them by hand.

An adapter such as @expressive/react is imported once, for its side effect, in your app's entry (import '@expressive/react'). It exports nothing for app code; Model, Component, the instructions and friends come from @expressive/mvc. The core package is a peer dependency of the adapter, so install both.

There is no Observable export. The protocol symbol is named Observer, on the /observable subpath. (That subpath also exports queued and capture - plumbing Model itself uses; you'll probably never need them.)


watch(target, callback, recursive?, scheduler?)

Run an effect against any observable, from anywhere. Same mechanism as state.get(effect), as a free function.

import { watch } from '@expressive/mvc/observable';

const state = Counter.new();

const stop = watch(state, (current) => {
  console.log('count:', current.count);
});

state.count++; // effect re-runs next microtask
stop();
function watch<T extends object>(
  target: T,
  callback: Effect<T>,
  recursive?: boolean,
  scheduler?: (work: () => void) => void
): () => void;

function watch<T extends object>(
  target: T,
  callback: Effect<Required<T>>,
  requireValues: true,
  scheduler?: (work: () => void) => void
): () => void;

The callback receives a tracking proxy and the list of changed keys - reads through the proxy decide what the effect follows.

Adapters may supply scheduler to bracket this subscriber's replay when it was queued by pending(work). The scheduler must invoke its callback synchronously; React passes startTransition. Omit it for normal effect timing.

  • Runs immediately if the target is active; otherwise waits for activation.
  • Re-runs are batched per microtask, once per settled update.
  • Return a cleanup function: it's called with true before a re-run, false when the effect stops, null when the target is destroyed.
  • Return null to make the effect one-shot. A returned Promise is ignored, so async effects are fine.
  • Pass true as the third argument to require values - reading an undefined property then throws Error: X.key is required in this context.

listener(target, callback, select?)

The raw event stream underneath everything else. The callback receives every signal the target emits:

import { listener } from '@expressive/mvc/observable';

const stop = listener(state, (key) => {
  if (key === true) console.log('ready');
  else if (key === false) console.log('update settled');
  else if (key === null) console.log('destroyed');
  else console.log('event:', key);
});
function listener<T extends object>(
  subject: T,
  callback: (key: Signal) => (() => void) | null | void,
  select?: Signal | Set<Signal>
): () => boolean;

Signal values:

  • string | number | symbol - a property updated, or a custom event fired.
  • true - the instance is ready (activation).
  • false - a batch of updates has settled.
  • null - the instance is destroyed; listeners are cleared after this.

Behavior worth knowing:

  • If the target is already active (and no select filter is given), the callback fires immediately with true.
  • Return null from the callback to unsubscribe.
  • Return a function to defer it until the current batch settles - the same function returned for several events in one batch runs only once.
  • Pass select (one signal or a Set) to filter which signals reach the callback.
  • The return value unsubscribes; it reports true if a listener was actually removed.

event(target, key?, silent?)

Dispatch a signal manually.

import { event } from '@expressive/mvc/observable';

event(state, 'my-event');           // key event - same as state.set('my-event')
event(state, Symbol.for('sync'));   // symbols make good custom events
event(state);                       // ready event - what Model.new() fires after construction
event(state, null);                 // terminal - same as state.set(null)

Pass silent: true to record a key into the current batch without notifying key listeners immediately.

event(target) with no key is the activation signal itself. This is exactly the difference between new Model() and Model.new() - the latter constructs, then calls event(instance).


touch(target, key, value?)

Report a read to whichever effect is currently watching. This is how Model properties register themselves for tracking - and how a plain class can join in.

function touch(from: object, key: any): void;
function touch<T>(from: object, key: any, value?: T): T;
  • Adds key to the active watcher's tracked set, then returns value.
  • If value is itself observable, returns a nested tracking proxy - deep tracking for free.
  • Under requireValues, an undefined value throws the "required in this context" error described above.
  • Outside any watcher, it's a no-op passthrough.

A custom observable

Combine touch, event and observer to make any class participate in the event system:

import { event, observer, touch } from '@expressive/mvc/observable';

class Counter {
  private value = 0;

  get count() {
    return touch(this, 'count', this.value);
  }

  set count(value: number) {
    this.value = value;
    event(this, 'count');
  }
}

const counter = new Counter();
observer(counter, true);

Now watch(counter, ...) works - no Model required. ✨

This is a headless seam. React components subscribe through a Component's render() or Model.get(), so reaching React with a custom observable means wrapping it in a Model; there is no hook that subscribes to an arbitrary object.


observer(target, create?)

Fetch (or attach) the dispatch record behind an observable.

function observer(state: object, create: true): Observer;
function observer(state: object, create?: boolean): Observer | undefined | null;
  • Returns the live record for an observable object.
  • Returns undefined if the object was never observable.
  • Returns null if it has been destroyed.
  • observer(obj, true) opts a plain object into the event system; it throws if the object was already terminated.

Observer is also exported - the unique symbol used as the protocol key on participating objects. Useful for tooling; rarely needed in application code.


pending(work)

Run work now, marking the subscriber updates it queues non-urgent. The callback is synchronous and its writes land immediately - only the notifications become pending.

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

pending(() => {
  router.path = '/settings';
});

React interprets the designation with startTransition, so a suspending subscriber can keep its previous content visible while the next presentation prepares. MVC still applies every mutation immediately and preserves its normal batching and final-state squashing.

The return value is a promise, resolving once every reader has absorbed the update - for React, once it commits; for a plain effect, once its replay returns. A reader which does not claim absorption resolves on replay instead of holding. Scheduling and settlement are independent: an adapter without concurrent deferral may still claim through its commit. Every reader is waited on, so this doubles as a barrier with no host at all.

An effect which suspends by throwing a promise holds settlement through its retry and any downstream updates the retry causes. Pending updates arriving meanwhile join the same hold and remain squashed into that retry. Fulfillment and rejection both retry through MVC dispatch. Cancelling the effect or destroying its instance releases the hold and prevents the settled promise from reviving it.

  • The scope is synchronous. Writes after an await or in a later callback need their own pending() call.
  • An exception from the callback propagates synchronously; updates queued before it threw still dispatch.
  • A call made inside another - or from a subscriber while it replays - settles its own consequences and also joins the outer call.
  • It covers Expressive updates, not everything in the callback. A raw React setter written alongside keeps its own priority - each subscriber replays through the scheduler it subscribed with, so the bracket lands per subscriber rather than around the block.
  • Updates which must remain urgent, including controlled-input state, belong outside pending().
  • If the same queued watcher receives both non-urgent and urgent invalidation, it runs once as urgent because it can present only the final squashed state.
  • Unrelated watchers retain their own priority; one urgent update does not upgrade the entire batch.
  • Without a host scheduler, work and dispatch keep their normal timing - the promise still resolves.

pending() - the subscriber half

Called with no arguments, from inside a subscriber's replay, pending() declares that subscriber has not absorbed its update yet. It returns a release callback, and the writer's promise waits on that rather than on the replay returning - which is how the React adapter holds settlement until it commits.

A promise returned by an effect remains ignored. To include other async work, claim during replay and release in finally:

watch(state, () => {
  const release = pending();
  animate().finally(release);
});

Only meaningful inside a replay carrying pending work; elsewhere it returns undefined.

The writer's promise never rejects. An exception thrown by work itself propagates synchronously from the call; a reader that throws while replaying is reported to catch handlers with kind effect and does not reach the writer.


unbind(fn)

Get the original prototype function back from an auto-bound method.

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

const raw = unbind(state.someMethod);

Model auto-binds methods on first access, so you can destructure and pass them around freely. unbind reverses that - handy when you need identity comparison against the prototype, or want to re-wrap the raw function.

  • state.get('someMethod') already returns the unbound original, so often you don't need unbind at all.

Context

The hierarchical registry behind <Component for> and the get instruction. Every active instance has a home context, assigned at activation, which decides where its state.get(Type) lookups originate.

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

const ctx = new Context({ AppState, UserState });
const app = ctx.get(AppState);

const child = ctx.push({ PageControl }, () => console.log('page closed'));
child.pop();

Constructor forms

new Context();                        // empty
new Context(parentContext);           // child of parent
new Context(StateClass);              // create, register and activate a state
new Context(stateInstance);           // register (and activate) an existing instance
new Context({ a: A, b: B });          // several at once

Methods

ctx.get(Type);                        // upstream lookup - throws if missing
ctx.get(Type, false);                 // optional - undefined if missing
ctx.get(Type, callback);              // upstream watch - returns unsubscribe
ctx.get(Type, callback, true);        // downstream watch
ctx.add(state, explicit?);            // register one instance (does not activate)
ctx.push(input?, cleanup?);           // create a child context with inputs registered
ctx.pop();                            // tear down this context and descendants
Context.get(state);                   // static - a state's home context
Context.get();                        // static - current context, else root
Context.get(Type, compute);           // static - resolve a static get (overrides)
Context.root;                         // process-wide registry
  • A failed ctx.get(Type) throws Could not find ${Type} in context.
  • ctx.push() and the constructor accept a class, an instance, or an object of either. Every input is registered before any activates, so siblings resolve each other with get. Classes are constructed and activated, and destroyed when the context pops; instances you passed in are left alone. Keys only name a bad input in the error. Inputs are registered once - there is no reconciling a later set.
  • ctx.push(input, cleanup) runs cleanup once when the child context pops - directly or with an ancestor - before its inputs are removed or destroyed.
  • ctx.add(state, explicit?) registers without activating. Explicit entries (true) win priority on lookup.
  • ctx.pop() recursively destroys descendant contexts and runs all cleanup.
  • state.get(Type, ...args) and the get instruction resolve Type.get from state's home context with state as origin, tracked through state when it is a tracking proxy.
  • Hosts install resolver((Type, compute) => ...) from @expressive/mvc/jsx-runtime to supply render context; its return value is what the caller receives. It is not consulted under an origin. Both hosts return the render's context for bare Context.get() in a render, and root elsewhere.

Home context

An instance's home is recorded once, at activation, and is permanent.

Activation pathHome becomes
Model.new()Context.root
new Context(StateClass)that context
new Model() then new Context(instance)that context
<Component for={StateClass}>its context

First-wins: once an instance has a home, no later context can transfer ownership.

A bare Model.new() resolves its get() lookups against root, but never registers there - see Root context.

Construct vs activate

The escape hatch for "create now, place later" is the difference between new Model() and Model.new():

// .new() activates immediately - init runs, and resolves get(), against root
const a = FormState.new();
new Context(a); // a's home from here, after init already ran without it

// new FormState() constructs without the activation event -
// the first explicit Context claims it before init runs
const b = new FormState();
new Context(b); // b's home is this context

This matters in tests, and in any code which prepares an instance before placing it in a context tree. (A plain-constructed instance can also be activated directly - await state.set() with no arguments does it.)

Child inheritance

Model-typed fields join their parent's home context at activation - children don't independently route to root:

class Parent extends Model {
  child = new Child();
}

const ctx = new Context(Parent);
ctx.get(Child); // the child instance - registered in ctx, not root

Recursive: grandchildren inherit through their immediate parent. Reassigning a child field destroys the old child (if owned via new Child()) and registers the replacement in the same context; externally-assigned children are not destroyed on replacement.

Root context

Context.root is a regular Context instance, the process-wide registry every host's contexts descend from. An instance activated by Model.new() outside an explicit context resolves its own lookups against root, but never registers itself there.

App-wide state belongs to a render-less Global Component at the app's top - what it owns is provided to everything below, so components read Session.get() directly rather than through Global. See it in action.

To make an instance findable from anywhere without a provider, pass root when creating it. The instance is added explicitly and removed when destroyed:

const theme = Theme.new(Context.root);
Context.root.get(Theme); // theme - and so does Theme.get(), anywhere

Good to know:
Instances held in root persist across tests. Context.root.pop() between tests clears them - see the testing guide.

Ambiguity

When implicit candidates share an ancestor type, both stay registered and a lookup at that ancestor returns null (ambiguous). Removing one heals it:

class Parent extends Model {
  foo: Foo | undefined = new Foo();
  bar = new Bar(); // Bar extends Foo
}

const ctx = new Context(Parent);
ctx.get(Foo); // null - ambiguous
ctx.get(Parent).foo = undefined;
ctx.get(Foo); // the Bar instance - healed

@expressive/mvc/hot

Class hot reload. You normally get it through a dev-server plugin (@expressive/react/vite, @expressive/dom/vite) rather than calling it yourself: an edited class is patched onto the one already loaded, so context, imports and instanceof keep working and live instances keep their values. Methods, getters, render, subcomponents and statics take the new definition; new(), constructor args and field initializers do not rerun. A change a patch can't carry - a field, the constructor or new(), a member switching between method and getter, a replaced parent class, or any class declaring #private members - replaces the class instead, and the module reloads.

function accept<T extends Record<string, unknown>>(id: string, classes: T): T;
function replaced(listener: (event: Replaced) => void): () => void;

interface Replaced {
  id: string;
  name: string;
  prev: Model.Extends;
  next: Model.Extends;
}
  • accept(id, classes) runs at the end of each module run. id is stable per module (the Vite plugins use the root-relative path), classes maps each top-level class by its local declaration name. It returns the class to use for each - the one first loaded, now patched, or the new one when the change is incompatible - and the module reassigns its bindings.
  • replaced(listener) reports each class accept replaced rather than patched, synchronously while the module re-runs, so a server host can retire instances of prev first. Returns an unsubscribe.

The registry is keyed by id, so it works wherever edited modules re-run while mvc stays loaded - the browser, and Vite's server module runner.


See also

  • Context guide - the JSX-facing surface: <Component for>, get instruction, patterns.
  • Model - set(callback) and get(effect), the instance-method forms of these helpers.
  • Model.get() - the subscription path for function components.

On this page