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.
Unmanaged properties
Not everything on a class is state the UI should watch. Subscription handles, timers, configuration and bookkeeping belong on the instance, but a write to them shouldn't cause a render. There are two ways to keep a field out of the reactive system, and they work together.
A _ prefix on a class field marks it as unmanaged. Read and write it like any other property - from methods, effects, computeds or render(), the write always lands on the instance. It never notifies anyone, it's hidden from get(), iteration and Object.keys(), and writing to it after the instance is destroyed doesn't throw. Constructor arguments, set({ ... }) and Component props can still assign it, so it's a good place for configuration a parent passes in without making it reactive. Like any prop, a _ prop the parent stops passing resets to undefined.
class Job extends State {
progress = 0; // reactive - the UI shows it
_unwatch: (() => void) | null = null; // unmanaged - just a handle
new() {
this._unwatch = api.subscribe((p) => { this.progress = p; });
return () => this._unwatch?.();
}
}#private fields are for logic that only the class itself touches. JavaScript enforces the privacy, and the reactive system never sees them. One catch: computed getters and effects run against a tracking proxy instead of the real instance, and a proxy can't read #private fields - this.#secret inside a computed throws.
Methods don't have this problem, because they're bound to the instance. Neither do _ accessors: a get _thing() or set _thing() always runs against the real instance, even when a computed or effect calls it. That makes a _ getter the bridge from private logic to reactive code:
class Session extends State {
user = '';
#token = '';
get _authorized() {
return this.#token !== '';
}
get greeting() {
return this._authorized ? `Welcome back, ${this.user}` : 'Please sign in';
}
signIn(user: string, token: string) {
this.#token = token;
this.user = user;
}
}A _ accessor never creates subscriptions, even for managed fields it reads. That follows from how tracking works: a computed or effect subscribes to whatever it reads through the tracking proxy. A _ accessor runs against the real instance instead, which is what lets it see #private fields, so its reads bypass the proxy and nothing is recorded.
This is intentional. A getter that quietly picked up dependencies would be a computed in disguise, without the caching and without being visible as reactive state. Keeping _ accessors inert means a _ name always means "outside the reactive system", inside and out. If a value should react to managed fields, make it a regular getter - a computed. Above, greeting refreshes because it reads user itself, which signIn changes. If _authorized read a managed field, greeting wouldn't pick it up that way.
In short:
| Reactive | Visible to computeds | Set by props / args | |
|---|---|---|---|
name = '' | yes | yes | yes |
_name = '' | no | yes, read and write | yes |
get _name() | no | yes, runs on the instance | with a setter; otherwise ignored |
#name = '' | no | only through a method or _ accessor | no |
See these in action in essentials/unmanaged and essentials/private.
TypeScript's private keyword is compile-time only - a private count = 0 field is still managed.
The _ treatment applies to class fields, set up when the instance activates. A key that first appears later - assigned in new(), say - is a plain property too, but an enumerable one, so it shows up in Object.keys(). Declare it as a _ field so the intent is visible on the class.
To hold a DOM element, or a mutable value whose changes other code should hear about, use ref.
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({
setup() {
// runs for each Timer on init
return () => {
/* cleanup on destroy */
};
}
});setup runs per-instance, before new(); ready runs after it, and type once per class - 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.