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
Observableexport. The protocol symbol is namedObserver, on the/observablesubpath. (That subpath also exportspendingandcapture- 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
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, 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
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.
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.- 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 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 |
<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 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. 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(), anywhereGood 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 evictedRead 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 - unambiguousExplicit 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 - healedRoot models "the global instance"; scoped contexts model "candidates available here". Different jobs, different rules.
See also
- Context guide - the React-facing surface:
Provider,getinstruction, patterns. - State -
set(callback)andget(effect), the instance-method forms of these helpers. - Hooks -
use(subject)consumes custom observables in React.