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
Observableexport. The protocol symbol is namedObserver, on the/observablesubpath. (That subpath also exportsqueuedandcapture- 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
truebefore a re-run,falsewhen the effect stops,nullwhen the target is destroyed. - Return
nullto make the effect one-shot. A returned Promise is ignored, so async effects are fine. - Pass
trueas the third argument to require values - reading anundefinedproperty then throwsError: 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
selectfilter is given), the callback fires immediately withtrue. - Return
nullfrom 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 aSet) to filter which signals reach the callback. - The return value unsubscribes; it reports
trueif 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 betweennew State()andState.new()- the latter constructs, then callsevent(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
keyto the active watcher's tracked set, then returnsvalue. - If
valueis itself observable, returns a nested tracking proxy - deep tracking for free. - Under
requireValues, anundefinedvalue 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
undefinedif the object was never observable. - Returns
nullif 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
awaitor in a later callback need their ownpending()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 needunbindat 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 onceMethods
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)throwsCould 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 path | Home 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 itsget()lookups against root either way, but it only registers into root - becoming findable by others - when the class declaresstatic 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 contextThis 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 rootRecursive: 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(), anywhereWithout 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.
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 BaseDestroy 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 - unambiguousExplicit 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 - healedRoot 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.idis stable per module (the Vite plugins use the root-relative path),classesmaps 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 classacceptreplaced rather than patched, synchronously while the module re-runs, so a server host can retire instances ofprevfirst. 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>,getinstruction, patterns. - State -
set(callback)andget(effect), the instance-method forms of these helpers. - Hooks -
State.useandState.get, the React subscription paths.