Expressive MVC
API Reference

State

The base State class - every method, static, and lifecycle hook

State is the base class you extend to create reactive state.

import State from '@expressive/react';
// or
import { State } from '@expressive/mvc';

Static methods

State.new(...args)

Create and activate an instance. Accepts State.Args - a mixture of initial-value objects, lifecycle callbacks, and nested arrays, all processed in order during activation.

const counter = Counter.new();
const counter = Counter.new({ count: 10 });
const counter = Counter.new({ count: 10 }, (self) => {
  // lifecycle callback
  return () => {
    /* cleanup on destroy */
  };
});

Always use .new() instead of new Counter(). The latter constructs but does not activate - properties aren't managed until activation.

Callbacks can return:

  • () => void - cleanup function, called on destroy.
  • object - merged as initial values.
  • array - flattened and re-processed.
  • Promise - caught and logged if rejected.

State.is(maybe)

Type guard. Returns true if maybe is an instance of this class or a subclass.

Counter.is(subCounter); // true if subCounter extends Counter

State.on(handler)

Register a lifecycle handler for this class and its subclasses. Returns a function to remove the handler.

The bare-function form is per-instance setup:

const stop = Counter.on(function (this: Counter) {
  // runs for every Counter constructed
  return () => {
    /* per-instance cleanup */
  };
});

Handlers run ancestor-first. The same handler registered on a parent and child only runs once.

Pass an object to hook by phase instead:

Counter.on({
  type(T) {
    /* once per class, at bootstrap */
  },
  before() {
    /* per instance - same slot as the bare form */
  },
  after() {
    /* per instance, at the new() slot */
  }
});

Activation is a queue: before handlers, then own fields become managed, then constructor args and the new() hook, then after handlers, then context registration. So each phase means:

  • type - runs once when the class first bootstraps, before its members are classified and bound. Receives the class itself, so a handler can inspect or reshape the prototype first. Registered on a base class, it runs for each subclass too.
  • before - per-instance, before properties are observed and before new(). A bare function passed to on is sugar for { before }. May return a cleanup, further constructor args, or an object of values to assign.
  • after - per-instance, at the new() slot - own values are observed and constructor args applied by then. May return a cleanup.

State.use(...args) (React)

Create an instance scoped to a React component. Covered in Hooks.

State.get(...) (React)

Look up an instance from React context. Covered in Hooks.


Instance methods

get() - export all values

const values = state.get();

Returns a frozen plain object of current values. Exotic values (refs, computed) are unwrapped via their internal .get(). Recursive - exports child states too. Handles circular references.

get(key, required?) - single property

state.get('count'); // returns the value
state.get('foo', true); // suspends if undefined
state.get('foo', false); // returns undefined without suspense
state.get('method'); // returns the unbound method

For exotic values like ref.Object, returns the unwrapped value. Method keys return the original, unbound function - the counterpart to auto-binding on normal access.

get(effect) - tracked effect

const stop = state.get((current, changed) => {
  console.log(current.count);
  return () => {
    /* cleanup */
  };
});
  • current - tracking proxy. Reads create subscriptions.
  • changed - readonly array of keys changed since last run (empty on first run, undefined if state wasn't ready).
  • Return a cleanup function, null (cancel), or void.
  • Cleanup receives true (about to re-run), false (cancelled), or null (state destroyed).
  • Suspense throws inside an effect pause the effect until resolved.

To watch a single property or event instead of tracking reads, use set(event, callback) below.

get(null) / get(null, callback) - destruction

state.get(null); // true if destroyed
state.get(null, () => console.log('destroyed')); // register callback

get(Type, required?) - context lookup

const parent = child.get(ParentState); // throws if not found
const maybe = child.get(ParentState, false); // undefined if not found

get(Type, callback, downstream?) - subscribe to context availability

state.get(ParentState, (parent, downstream) => {
  return () => {
    /* cleanup */
  };
});

Fires immediately if available; otherwise fires when the type becomes available. Pass downstream: true to only watch children.


set() - await pending flush

const updated = await state.set();
// updated: readonly string[] - keys that changed in the batch

Resolves when the current flush completes. Empty if no update is pending. Also activates the state if it was created with new instead of .new().

set(assign, silent?) - merge values

state.set({ count: 5, name: 'Alice' });
state.set(saved, true); // silent - no events, no throw if destroyed

Only known properties and methods are applied. Unknown keys are ignored. is is always ignored. Silent mode is useful during teardown.

Methods can be replaced:

state.set({
  compute() {
    return this.value + 1;
  }
});

What Assign checks

State.Assign<T> is Record<string, unknown> intersected with a mapped type over declared fields. The intersection means unknown keys are not rejected at the type level (they're ignored at runtime anyway); the only call-site protection is value-correctness of declared fields:

state.set({ count: 'no' }); // error - count is a number
state.set({ kount: 5 }); // fine by the type-checker - unknown key, ignored at runtime

Self-calls under polymorphic this

Calling this.set({ field }) from inside a subclassable class fails to type-check:

class Base extends State {
  path = '/';
  go() {
    this.set({ path: '/x' }, true);
    //        ^ Type '{ path: string }' is not assignable to 'Assign<this>'
  }
}

Inside the class body this is the polymorphic this type, so this[K] is unresolved and TypeScript cannot verify the literal's value types - and that value-check is the only thing Assign<this> enforces. Every keyof this-based alternative fails identically (a generic-inferred parameter, Partial<Values<this>>, even the keyed pair set('path', v) - the string literal isn't provably keyof this).

The fix is a cast to the concrete class, which loses no real safety - the value-check it bypasses was never available under polymorphic this:

(this as Base).set({ path: '/x' }, true);

This is a TypeScript limitation (mapped types over polymorphic this), not a bug here. External callers on a concrete instance (base.set({ path })) type-check normally. No looser parameter type can both accept the literal and value-check it while this is unresolved - it's pick-one, so the cast is the idiomatic escape hatch.

set(callback) - listen to all updates

const stop = state.set((key, self) => {
  console.log('updated:', key);
});

Fires synchronously for every property assignment that changes a value, plus explicit dispatches. stop() returns true if the listener was removed, false if it was already gone.

The callback can return:

  • A function - called once when the batch settles. Returning the same function for several events in one tick still runs it once - dedupe is by identity. If it throws, the error is logged.
  • null - auto-unsubscribe after this invocation.

set(key) - dispatch an event

state.set('count'); // force an update event without changing value
state.set('my-event'); // custom string event
state.set(Symbol('x')); // symbol event

Useful for signaling internal mutations (e.g. an array pushed) or custom events.

set(null) - destroy

state.set(null);

Destroys the instance. Children are destroyed first, listeners are notified, cleanup runs, the instance is frozen.

set(event, callback) - watch a specific key or event

const stop = state.set('count', (key, self) => {
  console.log('count is now', self.count);
});

state.set('my-event', onEvent); // custom events too
state.set(null, () => console.log('destroyed')); // null - destruction

The way to watch a single property. Fires on every assignment that changes the value, and on explicit set(key) dispatches - synchronously, before the flush settles.

Return semantics match set(callback): return a function to run once at settle (deduped), return null to auto-unsubscribe after one invocation, and stop() reports true/false.

set(key, descriptor) - define a property

state.set('foo', { value: 'bar' });
state.set('bar', { value: 'x', set: false }); // read-only
state.set('baz', { value: 'y', enumerable: false }); // non-enumerable
state.set('child', { value: new ChildState() }); // registers child

Creates or updates a managed property. If it already exists with a reactive getter/setter, only value is accepted.

Descriptor fields:

  • value - initial value.
  • get - custom getter, true (required/suspense), or false (optional).
  • set - custom setter function or false (read-only).
  • enumerable - default true.

Instance properties

.is

Non-enumerable self-reference. Two purposes:

  1. Write access after destructuring - const { is: counter, count } = state; counter.count = 5;
  2. Silent reads inside tracking contexts - current.is.value reads without subscribing.

state.is === state always, likewise state.is.is === state.is.


Lifecycle hooks

These are not on the State prototype. Define them on your class to opt in.

new()

class Timer extends State {
  elapsed = 0;

  protected new() {
    const id = setInterval(() => this.elapsed++, 1000);
    return () => clearInterval(id);
  }
}

Runs once after activation. Return a cleanup function to run on destruction, or void.

use(...props) (React)

class SearchState extends State {
  query = '';

  use(props: { initialQuery: string }) {
    const { pathname } = useLocation();
    this.query = props.initialQuery;
  }
}

Runs on every render when used via State.use(). Parameter types define the argument types of State.use() itself. Use for bridging external React hooks.


Iteration

for (const [key, value] of state) {
  // yields managed properties
}

Types

See Types for State.Extends, State.Type, State.Field, State.Args, State.On, State.Values, and friends.

On this page