Expressive MVC
API Reference

Instructions

Field initializers - set, get, ref, def - with every overload

Instructions are special initializers for class fields. They wire up reactive behavior declaratively.

import { set, get, ref, def } from '@expressive/react';

All instructions return a symbol at definition time that the library resolves into a real typed value during activation. TypeScript sees the declared type; the runtime sees the symbol until init.


set

The most versatile instruction. Handles default values, placeholders, lazy/async factories, validation callbacks, and reactive computed values.

For reactive computed values, a normal class getter usually reads best - getters on a State subclass are auto-promoted to memoized, dependency-tracked properties. See Reactive computed (getters) below, and set(compute) for the instruction equivalent.

Property descriptor policy

FormEnumerableWritableNotes
= "foo" (plain)yesyesNormal data property
= set("foo")noyesManaged default, hidden from Object.keys()
= set("foo", cb)noyesManaged with setter callback
= set(() => "foo")nonoFactory, read-only
= set(() => "foo", cb)noyesFactory made writable by callback
= set(self => ...)yesnoReactive computed via instruction
get foo() { ... }yesnoReactive computed, included in snapshots
= set<T>()noyesPlaceholder, suspends on access

set<T>() - placeholder

userId = set<string>();

Required. Throws Suspense on read until assigned. Writable. Non-enumerable.

Optional setter callback:

userId = set<string>(undefined, (next, prev) => {
  /* ... */
});

set(value) - default value

name = set('untitled');

Like name = 'untitled' but non-enumerable - hidden from Object.keys(), spread, and ref(this).

set(value, callback) - default with validation

email = set('', (next, prev) => {
  if (!next.includes('@')) throw false; // reject
});

Callback behaviors:

  • throw false - reject. Value does not change. No event.
  • throw true - accept silently. Value changes, no event emitted.
  • Return a function - cleanup, called before next update.
  • Throw an Error - rethrown to caller.
  • Return a promise - ignored.

set(factory) - lazy factory

config = set(() => loadConfig());
data = set(async () => fetchData()); // async, suspends on access

Zero-argument function. Runs on first access. Read-only.

set(factory, true) - eager factory

data = set(() => fetch(), true);

Runs immediately on activation. Still read-only.

set(factory, false) - lazy factory, no suspense

avatar = set(async () => loadAvatar(), false);

Returns undefined while pending instead of suspending.

set(factory, callback) - factory with setter

config = set(
  () => loadDefaults(),
  (next, prev) => {
    /* ... */
  }
);

Makes the property writable. Callback runs on factory resolution and subsequent assignments.

set(compute) - reactive computed

class Person extends State {
  first = 'John';
  last = 'Doe';
  full = set((self) => `${self.first} ${self.last}`);
}

The instruction equivalent of a getter. Unlike a zero-arg factory (which runs once and caches), a function declaring a parameter routes into the reactive compute engine: it re-runs whenever any managed property it reads is updated. The instance is passed as both this and the first argument, so arrows and regular functions both work.

Dispatch is by arity. set(() => x) is a one-shot factory; set(self => x) is reactive. A function that reads dependencies via this still needs to declare the parameter - set(function (self) { return this.x }) - to be treated as computed.

  • Enumerable and read-only, exactly like a prototype getter. Computes lazily on first access.
  • Because the slot is instruction-assigned rather than a concrete getter, a subclass can refine its type with declare - a parent may declare a generic computed property that subclasses narrow, which a getter on the parent class cannot express.

Reactive computed (getters)

class Cart extends State {
  items = set<Item[]>([]);
  get total() {
    return this.items.reduce((s, i) => s + i.price, 0);
  }
}

A getter declared on a State subclass is auto-promoted to a reactive computed property. Enumerable, read-only. Re-runs when tracked dependencies change.

  • this is a tracking proxy when the getter runs in a subscriber context - reads create subscriptions.
  • To opt out of tracking for a single read, use this.is.ownProp.
  • Chained computed values evaluate in declaration order.
  • Can reference its own previous value via this.ownProp without looping.
  • Subclasses can override a getter; super.foo composes with the parent.
  • Getters declared with a paired setter (get foo() {} set foo(v) {}) are NOT promoted - they remain plain JS accessors.
  • Included in snapshots and ref(this).

Direct promises throw

data = set(somePromise); // TypeError during activation

Passing a raw Promise fails fast: TypeError: Direct promises are not supported in set({state}.{key}). Use set(() => promise) instead.

Use a factory (set(() => promise) or set(async () => value)) so async work starts during activation/access. A promise constructed in a field initializer can keep running for instances React abandons, especially under StrictMode.


get

Context lookup. Fetches another State from the ambient context hierarchy.

get(Type) - required upstream

parent = get(ParentState);

Throws "Required {Type} not found in context for {subject}." if missing.

get(Type, false) - optional upstream

maybe = get(OptionalSvc, false); // T | undefined

get(Type, callback) - upstream with lifecycle

parent = get(ParentState, (parent, self) => {
  console.log('attached:', parent);
  return () => console.log('detached');
});

Callback runs when upstream is found/replaced. Return a cleanup function, or void.

get(Type, true) - downstream collection

tabs = get(Tab, true); // readonly Tab[]

Collects all instances of Type below in the context tree. Updates as children are added or removed. Subclasses match; superclasses do not.

get(Type, true, callback) - downstream with lifecycle

items = get(Item, true, (item, self) => {
  console.log('registered:', item);
  return () => console.log('removed');
});

Return false from the callback to prevent registration. Return a function for cleanup on removal.

get(Type, true, true) - downstream single (required)

form = get(FormState, true, true);

get(Type, true, false) - downstream single (optional)

form = get(FormState, true, false); // T | undefined

Behavior notes

  • All get() properties are non-enumerable - hidden from Object.keys(), spread, and ref(this).
  • Upstream lookups check the direct parent first, then walk the context hierarchy.
  • A state does not resolve itself.
  • Upstream callbacks are not reactive - they fire once per mount.
  • Downstream collections ignore redundant registrations of the same instance.
  • Downstream callbacks run their cleanup before either side is destroyed.

ref

Mutable reference holder - like React's useRef, declared on the class.

ref<T>() - basic ref

element = ref<HTMLDivElement>();
element.current; // HTMLDivElement | null
element.current = div;

Returns a ref.Object<T> - simultaneously callable (like a React ref function) and a { current } object.

ref<T>(callback) - ref with callback

node = ref<HTMLElement>((el) => {
  console.log('attached:', el);
  return (next) => console.log('replaced with:', next);
});

Callback fires when the value is set (not on null by default). Returned function runs on replacement, receiving the new value.

ref<T>(callback, false) - callback includes null

node = ref<HTMLElement>((el) => {
  console.log('value is:', el); // fires for null too
}, false);

ref(this) - ref proxy

class Form extends State {
  name = '';
  email = '';
  refs = ref(this);
}

form.refs.name; // ref.Object<string>
form.refs.name.current; // current value of form.name
form.refs.name.current = 'new'; // updates form.name

Creates ref objects for every enumerable property. Must pass this - any other argument throws.

  • Reactive computed getters are included (read-only).
  • Factory-based properties (set(() => ...)) are excluded (non-enumerable).

ref(this, mapFn) - custom ref proxy

class Form extends State {
  name = '';
  inputs = ref(this, (key) => createInput(key));
}

Map function runs lazily once per key; return value is cached.

ref.Object<T> shape

interface ref.Object<T> {
  current: T;
  is: State;
  key: string;
  get(): T | null;
  get(callback: (value: T) => void): () => void;
}

def

Low-level primitive for building custom property behavior. All other instructions are built on def. Reach for it when you want reactive semantics that don't fit set, get, or ref.

custom = def((key, subject, state) => ({
  value: 'initial',
  enumerable: true,
  get: (source) => computedValue,
  set: (next, prev) => {
    /* ... */
  },
  destroy: () => {
    /* cleanup */
  }
}));

The factory runs during activation and can return:

  • void - side effect only, no property config.
  • () => void - cleanup function only.
  • A def.Config<T> object - full property configuration.

Timing: the symbol placeholder an instruction leaves on the field is deleted before the factory runs. Inside the factory the property does not exist yet - whatever config you return is what defines it.

Config fields

FieldTypeDescription
valueTInitial value
enumerablebooleanAppear in Object.keys() (default true)
get(source: State) => T | true | falseCustom getter, or suspense/optional flag
set(next: T, prev: T) => void | T | falseCustom setter, or false for read-only
destroy() => voidCleanup on destruction

Setter callbacks behave like set() callbacks: throw false to reject, throw true to accept silently, return a value to transform.

Nesting states needs no instruction at all. child = new ChildState() is auto-parented, activated, and destroyed with its parent.


hot

Keyed reactivity for arrays and objects.

import { hot } from '@expressive/react';

class Game extends State {
  board = hot(Array(9).fill(''));

  play(index: number) {
    this.board[index] = 'X';
  }
}

hot() is an export alongside the instructions, but not technically an instruction - it doesn't return a placeholder symbol. It returns a reactive proxy you can assign to a State field, or use standalone with watch(). Reads register subscriptions in active effects; writes notify only the keys that changed.

  • Array reads track the accessed index or length; native methods (slice, find, push, splice, ...) subscribe to what they actually read.
  • Arrays must stay dense - operations that would create holes throw.
  • Object reads and writes are tracked per property key.
  • Shallow - nested arrays and objects are not wrapped; use child States or another hot().
function hot<T>(value: T[]): T[];
function hot<T extends object>(value: T): T;

The deep dive - the dense-array contract with its exact throws, keyed tracking through native methods, nesting recipes, and the shared-storage caveat - lives at Arrays & Collections.

On this page