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. Some of it re-exports through @expressive/react and some deliberately does not, so the import paths matter here.


Where things live

The core package has two entry points:

// 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';

@expressive/react re-exports the everyday surface and nothing else:

import State, {
  Component,
  Consumer,
  Context,
  def,
  get,
  has,
  map,
  pending,
  Provider,
  ref,
  set
} from '@expressive/react';

Note what's absent: unbind and all of the observable helpers. A React project that wants watch or listener imports them from @expressive/mvc/observable directly - the core package is a dependency of the adapter, so it's already in your node_modules.

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/react';

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 logged 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.
  • Not exported by @expressive/react - import from @expressive/mvc.

Context

The hierarchical registry behind Provider 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
<Provider for={StateClass}> (React)Provider 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

Two implicit instances of the same global class in root mutually evict at the contested ancestor:

const a = Sub.new(); // Sub declares `static global`
const b = Sub.new();
Context.root.get(Sub, false); // undefined - both evicted

Read it as "implicit collision opts out of global lookup" - if you create two, neither is the global instance. A third Sub.new() reclaims the now-empty slot.

Eviction is per-ancestor, so sibling subtypes only collide at their shared supertype:

// 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), JSX Provider) 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.


See also

  • Context guide - the React-facing surface: Provider, 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