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
| Form | Enumerable | Writable | Notes |
|---|---|---|---|
= "foo" (plain) | yes | yes | Normal data property |
= set("foo") | no | yes | Managed default, hidden from Object.keys() |
= set("foo", cb) | no | yes | Managed with setter callback |
= set(() => "foo") | no | no | Factory, read-only |
= set(() => "foo", cb) | no | yes | Factory made writable by callback |
= set(self => ...) | yes | no | Reactive computed via instruction |
get foo() { ... } | yes | no | Reactive computed, included in snapshots |
= set<T>() | no | yes | Placeholder, 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 accessZero-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 viathisstill 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.
thisis 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.ownPropwithout looping. - Subclasses can override a getter;
super.foocomposes 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 activationPassing 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 | undefinedget(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 | undefinedBehavior notes
- All
get()properties are non-enumerable - hidden fromObject.keys(), spread, andref(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.nameCreates 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
| Field | Type | Description |
|---|---|---|
value | T | Initial value |
enumerable | boolean | Appear in Object.keys() (default true) |
get | (source: State) => T | true | false | Custom getter, or suspense/optional flag |
set | (next: T, prev: T) => void | T | false | Custom setter, or false for read-only |
destroy | () => void | Cleanup 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.