Model Classes
Defining properties, methods, and lifecycle on Model
Everything in Expressive starts here. Reactivity, components, context, async - all of it builds on a class that extends Model.
import { Model } from '@expressive/mvc';
class Counter extends Model {
count = 0;
increment() {
this.count++;
}
}Instantiation
Create instances with Model.new(), not new Model():
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, rendering a Component or placing a class with <Component for={Counter}> 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 instance 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');
});Properties
Class fields become reactive automatically. Assign, and subscribers hear about it:
class App extends Model {
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 isn't reported. 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 Model {
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 _ members: a _method(), 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 Model {
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 - except _ methods, which stay unbound like plain class methods:
const { increment } = Counter.new();
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.get();
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 instance's 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
An instance has four phases:
| Phase | Trigger | What happens |
|---|---|---|
| Construction | new Timer() | Fields are set to their initial values, no reactivity yet |
| Activation | Model.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, dispatch stops |
The new() hook
Override new() for one-time setup that accompanies the instance itself. Return a cleanup function to run on destruction:
class Cart extends Model {
items: string[] = [];
updatedAt = 0;
protected new() {
return this.set('items', () => {
this.updatedAt = Date.now();
});
}
}new() runs once, after fields are initialized and constructor arguments are applied - wherever the instance is created, server render included. Everything on this is live by then - assign, read, call methods, register effects. Hot reload doesn't re-run it either: an edit to new() reloads the page, while other edits patch live instances in place.
Timing: the constructor body (and
Model.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 Model {
url = '/api';
constructor(...args: Model.Args) {
super(...args, () => {
// runs like new(), but a subclass can't override it
this.url = this.url.replace(/\/$/, '');
});
}
}The mount() hook
Client-only resources - timers, DOM, subscriptions to window - belong in mount(), not new(). It runs once when the owning component commits, and its returned function runs on unmount:
class Timer extends Model {
elapsed = 0;
mount() {
const id = setInterval(() => this.elapsed++, 1000);
return () => clearInterval(id);
}
}mount() never runs during server render - there is no unmount there, so a resource opened in new() would leak. It fires for a Component rendered as JSX, or for a class given to <Component for>; an instance merely read with .get() or placed as {instance} is not owned, so it does not. See mount() for the reference.
Class-level setup with Model.on()
To run setup for every instance of a class (and its subclasses), register a handler with Model.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: Model.
A catch handler hears failures no caller was positioned to catch - a rejected async initializer (kind setup), a getter or effect throwing during an update (getter, effect), a write after destruction (dead), an instance constructed but never activated (unused). It receives the raw error, the kind string and, where one applies, the key. Return nothing to handle it, or return the error to pass it on. Passed off the end, a dead write outputs nothing, unused warns, and the rest escape uncaught.
Model.on({
catch(error, kind, key) {
if (kind == 'unused') return error;
tracker.capture(error, { state: String(this), kind, key });
}
});Bridging React hooks
A Model class doesn't call hooks - it holds state, and React hands it values. When a class needs something only a hook provides, give it a field and let the view supply it. With a Component, fields are props, so an outer function component can read the hook and pass the value down:
import { useLocation } from 'react-router-dom';
class SearchPage extends Component {
query = '';
render() {
const { query } = this;
// ...
}
}
function SearchRoute() {
const { search } = useLocation();
return <SearchPage query={new URLSearchParams(search).get('q') ?? ''} />;
}Values computed from those inputs are getters, recomputed when the input changes:
class Greeter extends Component {
name = '';
get greeting() {
return `Hello, ${this.name}`;
}
render() {
const { greeting } = this;
return <p>{greeting}</p>;
}
}
<Greeter name="Ada" />;A Component's render() also runs as a real function component, so a hook whose result the view calls directly - useNavigate, useTranslation - can sit at the top of render(). For one-shot setup, prefer new() or mount().
Destruction
state.set(null);This is automatic in React - a Component destroys its instance on unmount, and so does <Component for={Timer}> for the instance it constructs. 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. - Dispatch stops - no further events or effects.
A write that lands afterward - a late fetch, a timer nobody cleared - is stored without notifying anyone, so the code that made it runs to its end. Each such write is reported to catch handlers with kind dead; unhandled, it outputs nothing. One late completion is harmless; repeated late writes mean work outlived its owner, so cancel it in a cleanup rather than guarding writes. Subscribing to a destroyed instance still throws.
Child instances
Assign an instance to a field and it becomes a managed child:
class Address extends Model {
street = '';
city = '';
}
class User extends Model {
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. - An instance passed in from outside is not owned - replacing it does not destroy it.
Child instance changes propagate through nested subscriptions:
function UserStreet() {
const {
address: { street }
} = User.get();
// Re-renders only when user.address.street changes.
}Constructor arguments
Beyond initial values and callbacks, .new() accepts arrays of arguments (flattened) and a Context to make the instance's home - given several, the first wins. A callback may return a cleanup, an object to assign, more arguments, or a promise - a rejected promise is reported to catch handlers with kind setup and, unhandled, escapes uncaught. 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 Model.Args in the API reference.
Inheritance
Model classes inherit normally. Base classes can define shared fields, methods, and instructions:
abstract class Query<T> extends Model {
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 Model 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: Model - every method and static on the base class.