Expressive MVC
API Reference

Utilities

Framework-agnostic helpers - 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,
  hot,
  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,
  hot,
  Provider,
  ref,
  set,
  use
} 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 pending and capture - plumbing State itself uses; you'll probably never need them.)


watch(target, callback)

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
): () => void;

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

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

  • 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, and so does React's use(counter) hook. No State required. ✨


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.


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.

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. Any state activated by State.new() outside an explicit context lands here - which is why a module-scope Theme.new() is findable app-wide with no Provider:

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

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 type in root mutually evict at the contested ancestor:

const a = Sub.new();
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:

class SubA extends Base {}
class SubB extends Base {}

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 - use(subject) consumes custom observables in React.

On this page