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 - State and friends
import {
  State,
  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'). Its re-exports of State, Component, the instructions and friends - and its default export - are deprecated; import those 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 State 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 State.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 State() and State.new() - the latter constructs, then calls event(instance).


touch(target, key, value?)

Report a read to whichever effect is currently watching. This is how State 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 State required. ✨

This is a headless seam. React components subscribe through State.use() or State.get(), so reaching React with a custom observable means wrapping it in a State; 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 state 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);

State 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 State 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({ ChildState });
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.set(inputs, forEach?);            // reconcile registered states with inputs
ctx.push(inputs?);                    // create a child context
ctx.pop();                            // tear down this context and descendants
Context.get(state);                   // static - a state's home context
Context.root;                         // global registry
  • A failed ctx.get(Type) throws Could not find ${Type} in context.
  • ctx.set() accepts a class, an instance, or a map of either. Classes are constructed and activated; when inputs change, removed entries run their cleanup and instances the context created are destroyed. Instances you passed in are left alone.
  • ctx.add(state, explicit?) registers without activating. Explicit entries (true) bypass global eviction and win priority on lookup.
  • ctx.pop() recursively destroys descendant contexts and runs all cleanup.
  • Adapters may override Context.get - the React adapter returns the ambient React context when called with no argument.

Home context

A state's home is recorded once, at activation, and is permanent.

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

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

A bare State.new() resolves its get() lookups against root either way, but it only registers into root - becoming findable by others - when the class declares static global.

Construct vs activate

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

// .new() activates immediately - home is root, locked
const a = MyState.new();
new Context(a); // does NOT change a's home

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

This matters in tests, and in any code which prepares a state 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

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

class Parent extends State {
  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 and globals

Context.root is a regular Context instance serving as the global registry. A state activated by State.new() outside an explicit context resolves its own lookups against root either way, but it only registers here - becoming findable app-wide with no provider - when the class declares static global:

class Theme extends State {
  static global = true;
}

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

Without the declaration, a context-less instance stays private. static global also takes a resolver (self => boolean) to decide at activation, and must be declared per class - a subclass that would be global purely by inheritance throws on activation.

See globals in action.

Good to know:
Module-scope globals persist across tests. Context.root.pop() between tests clears them - see the testing guide.

Global collision

A global may not shadow another. Activation throws if root already resolves the type - the same class, or an instance of a subclass - or if the new instance would hide an instance whose class is one of its supertypes:

const a = Sub.new(); // Sub and Base both declare `static global`
Sub.new();           // throws - a already exists in root
Base.new();          // throws - a resolves Base

Destroy the existing instance first (a.set(null)) to register another - for example, to replace a global with a subclass in a test.

Sibling subtypes don't shadow each other - they collide only at their shared supertype, where both evict:

// Base is a widened global; each subtype re-declares (required on extend)
class SubA extends Base { static readonly global = true; }
class SubB extends Base { static readonly global = true; }

const a = SubA.new();
const b = SubB.new();

Context.root.get(Base, false); // undefined - contested at Base
Context.root.get(SubA);        // a - unambiguous
Context.root.get(SubB);        // b - unambiguous

Explicit registration (new Context(state), ctx.add(state, true), <Component for>) bypasses eviction entirely; implicit and explicit entries coexist and explicit wins on lookup.

Ambiguity at non-root

Scoped contexts use softer collision semantics: implicit candidates both stay registered, and a lookup at the shared ancestor returns null (ambiguous) rather than evicting. Removing one heals it:

class Parent extends State {
  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

Root models "the global instance"; scoped contexts model "candidates available here". Different jobs, different rules.


@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: State.Extends;
  next: State.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.
  • State - set(callback) and get(effect), the instance-method forms of these helpers.
  • Hooks - State.use and State.get, the React subscription paths.

On this page