State Classes
Defining properties, methods, and lifecycle on State
Everything in Expressive starts here. Reactivity, components, context, async - all of it builds on a class that extends State.
import State from '@expressive/react';
class Counter extends State {
count = 0;
increment() {
this.count++;
}
}Instantiation
Create instances with State.new(), not new State():
const counter = Counter.new();new Counter() constructs the object but does not activate it - properties aren't managed until activation happens. Counter.new() does both: it runs the constructor, promotes fields to managed properties, applies constructor arguments, and fires the new() lifecycle hook.
Inside React, Counter.use() activates for you. Outside React, reach for .new().
Good to know:
If you're stuck holding a plain-constructed instance,await state.set()will activate it - the no-arg form fires the ready event if it hasn't fired already. Deferring activation on purpose is an advanced move (it controls where the state lands in context), but the escape hatch is there.
Initial values and callbacks
.new() accepts objects (initial values), functions (lifecycle callbacks), and arrays of either:
const counter = Counter.new({ count: 10 }, (self) => {
console.log('ready');
return () => console.log('destroyed');
});The same signature works for Counter.use().
Properties
Class fields become reactive automatically. Assign, and subscribers hear about it:
class App extends State {
name = 'World';
count = 0;
}
const app = App.new();
app.name = 'Alice'; // notifies subscribers
app.count += 1; // notifies subscribersAll writes in the same tick batch into a single flush (via queueMicrotask), and a write that's === the previous value is a no-op - no event at all.
Non-reactive properties
Properties are discovered at activation, by walking the instance's own keys. So a key that first appears afterward - say, assigned inside new() - is just a plain JS property. No events, invisible to subscribers, absent from iteration:
class Stopwatch extends State {
elapsed = 0; // reactive
declare startedAt: number;
new() {
this.startedAt = Date.now(); // plain property - never managed
}
}For the common case - holding a DOM element or a mutable handle - use ref instead.
Methods
Methods are auto-bound on first access. Destructure them freely:
const { increment } = Counter.use();
increment(); // `this` is correct- Passing a method as an event handler just works - no
useCallback, no.bind(this). supercalls work across inheritance chains.- Overwriting a method works:
counter.increment = () => { ... }.
Methods called inside a tracked effect do not create subscriptions for the properties they read. This is deliberate: methods are "actions", not "observations". If you want an effect to re-run when a method's inputs change, read those properties through the tracking proxy directly.
The is property
Every instance has a non-enumerable is property that loops back to the instance. It serves two purposes.
Write access after destructuring
const { is: counter, count } = Counter.use();
counter.count = 5; // worksDestructuring breaks the binding between a local variable and the property, so count = 5 just reassigns the local. is hands you back the live instance. Destructure it first so the handle is easy to spot, and alias it to the state concept (counter, form, dashboard) rather than keeping a local named is.
Silent reads
Inside a tracking context (an effect, a computed, a component render), reading a property subscribes to it. Reading the same property via is bypasses tracking:
state.get((current) => {
console.log(current.value); // subscribes to `value`
console.log(current.is.other); // does NOT subscribe
});Silent reads are for when you need a value at a point in time, but a change to it shouldn't trigger a re-run.
Lifecycle
A State has four phases:
| Phase | Trigger | What happens |
|---|---|---|
| Construction | new MyState() | Fields are set to their initial values, no reactivity yet |
| Activation | State.new() | Properties become managed, constructor args run, new() hook fires |
| Operation | Property assignment | Batched updates flush via microtask, effects re-run |
| Destruction | state.set(null) | Children destroyed first, listeners notified, state frozen |
The new() hook
Override new() for one-time setup. Return a cleanup function to run on destruction:
class Timer extends State {
elapsed = 0;
protected new() {
const id = setInterval(() => this.elapsed++, 1000);
return () => clearInterval(id);
}
}new() runs once, after fields are initialized and constructor arguments are applied. Everything on this is live by then - assign, read, call methods, register effects.
Timing: the constructor body (and
State.onsetup) runs before arguments merge, so it only ever sees field defaults.new()runs after - making it the right place to read a value passed via.new({ ... }), or props in adapters.
One caveat for library authors: new() is a public method. A subclass that defines its own new() silently replaces yours - no error, no forced super call. Fine in app code; risky in a reusable base class. For init logic that must survive subclassing, pass a trailing callback to super instead. It runs in the same phase, but can't be clobbered:
class Resource extends State {
url = '/api';
constructor(...args: State.Args) {
super(...args, () => {
// runs like new(), but a subclass can't override it
this.url = this.url.replace(/\/$/, '');
});
}
}Class-level setup with State.on()
To run setup for every instance of a class (and its subclasses), register a handler with State.on():
const stop = Timer.on(function () {
// runs for each Timer on init
return () => {
/* cleanup on destroy */
};
});A bare function runs per-instance, before new(). There's also an object form ({ type, before, after }) for hooking specific phases - see API: State.
The use() hook (React only)
If you define a use() method on a class, State.use() will call it on every render. This is the bridge for calling React hooks from inside a State class:
import { useLocation } from 'react-router-dom';
class SearchState extends State {
query = '';
use() {
const { search } = useLocation();
this.query = new URLSearchParams(search).get('q') ?? '';
}
}When use() is defined, its parameter types become the arguments SearchState.use() accepts:
class Greeter extends State {
greeting = '';
use(props: { name: string }) {
this.greeting = `Hello, ${props.name}`;
}
}
function App({ name }: { name: string }) {
const { greeting } = Greeter.use({ name });
return <p>{greeting}</p>;
}Use use() for every-render bridging. For one-shot setup, prefer new().
Destruction
state.set(null);This is automatic in React - State.use() destroys on unmount, Provider destroys on unmount. You rarely call it yourself. When destruction runs:
- Children are destroyed first, inner-to-outer.
- Listeners are notified with a
nullsignal. - Effect cleanups run with their argument set to
null. - The
new()hook's returned cleanup is called. - The state is frozen - further writes throw.
Child states
Assign a State to a field and it becomes a managed child:
class Address extends State {
street = '';
city = '';
}
class User extends State {
name = '';
address = new Address(); // owned child, auto-activated, auto-destroyed
}- Owned children activate with the parent and destroy with the parent.
- Replacing a child property (
this.address = new Address()) destroys the old one. - Setting an owned child to
nulldestroys it. - A state passed in from outside is not owned - replacing it does not destroy it.
Child state changes propagate through nested subscriptions:
function UserStreet() {
const {
address: { street }
} = User.use();
// Re-renders only when user.address.street changes.
}Constructor arguments
Beyond initial values and callbacks, .new() / .use() accepts arrays of arguments (flattened) and promises (caught and logged if rejected). All are applied in order during activation:
const t = Test.new(
{ foo: 1 },
(self) => {
// lifecycle callback
return () => {
/* cleanup */
};
},
{ bar: 2 }
);For framework code accepting arbitrary args, see State.Args in the API reference.
Inheritance
State classes inherit normally. Base classes can define shared fields, methods, and instructions:
abstract class Query<T> extends State {
abstract url: string;
data: T | null = null;
loading = false;
error = set<Error | null>(null);
async fetch() {
this.loading = true;
this.error = null;
try {
const res = await fetch(this.url);
this.data = await res.json();
} catch (e) {
this.error = e as Error;
} finally {
this.loading = false;
}
}
}
class UserQuery extends Query<User> {
url = '/api/user';
}Subclasses can override methods and add fields. Instructions defined on the base class are inherited and re-initialized per instance.
Iteration
A State instance is iterable, yielding [key, value] pairs for its managed properties:
for (const [key, value] of state) {
console.log(key, value);
}Next
- Reactivity - tracking, computed values, effects, and batching.
- Components - the
Componentclass for self-rendering state. - API: State - every method and static on the base class.