Expressive MVC
Guides

Reactivity

How tracking, computed values, effects, and batching work

Expressive is reactive by default. You don't call setState, you don't dispatch actions, and you don't declare dependencies. You assign to properties, and anything that read those properties updates.

This guide explains how that works, so you can trust it. If you'd rather poke at it first, see it running.


Tracking

Whenever you read a property inside a tracking context, you subscribe to it. A tracking context is one of:

  • A React component calling State.use() or State.get().
  • An effect passed to state.get(effect).
  • A computed: a class getter on a State subclass.

Reads through a tracking proxy subscribe. Reads through the plain instance do not.

state.get((current) => {
  console.log(current.value); // subscribes to `value`
  console.log(state.value); // does NOT subscribe (outside the proxy)
  console.log(current.is.other); // does NOT subscribe (silent read)
});

The rule is pretty simple: if the read goes through the proxy, it's tracked. Method calls do not create subscriptions for the properties they touch - methods are "actions", not "observations". If you want an effect to depend on what a method reads, read those values through the proxy directly.

A few more things tracking handles for you:

  • Nested child-state reads track deeply (current.child.value).
  • Replacing a child re-subscribes to the new one automatically.
  • Several tracked properties changing in one batch means one re-run, not several.

Properties are the unit of tracking, so mutating an array in place (items.push(...)) goes unseen - the property was never assigned. See Arrays & Collections for the ways around that, including hot() for per-index tracking.

React components

A component's tracking context comes from State.use() or State.get(). Destructuring is the usual way to read tracked values:

function CartView() {
  const { total, count } = Cart.use();
  // Subscribes to `total` and `count`. Re-renders when either changes.
  return (
    <p>
      {count} items - ${total}
    </p>
  );
}

The component re-renders when a tracked property changes, and only then. A sibling updating items while you only read total won't touch this component unless total actually changed value.


Computed values

A computed is a property derived from other properties. Use a normal class getter:

class Cart extends State {
  items: Item[] = [];
  get total() { return this.items.reduce((s, i) => s + i.price * i.qty, 0); }
  get count() { return this.items.reduce((s, i) => s + i.qty, 0); }
}

A getter on a State subclass is automatically reactive. this inside the getter is a tracking proxy, so any managed property you read subscribes the computed to it.

  • Computed values are enumerable and read-only - they're considered data.
  • They're evaluated lazily - deferred until something reads them or until the next flush. The result is cached; it only recomputes when a dependency changed.
  • When the value changes, an update event fires under the computed's own name - subscribers can watch the computed directly, without knowing its inputs.
  • To opt a single read out of tracking, go through this.is instead.
  • Chained computed values (one referencing another) evaluate in declaration order.
  • A computed can reference its own previous value via this.ownProp without infinite looping.
  • Subclasses can override a getter; super.foo composes with the parent's, and dependencies from both are picked up.
class Stats extends State {
  values: number[] = [];
  get count() { return this.values.length; }
  get sum() { return this.values.reduce((a, b) => a + b, 0); }
  get average() { return this.count === 0 ? 0 : this.sum / this.count; }
}

There's also an instruction form, full = set(self => ...) - same semantics as a getter, but a subclass can refine its type with declare, which a getter can't express. See API: Instructions.

When a getter is not a computed

Not every accessor gets promoted. A getter stays a plain JS accessor (no tracking, no caching) when:

  • it has a paired setter: get foo() {} + set foo(v) {}
  • it was installed via Object.defineProperty on the prototype - descriptors made that way default to configurable: false
  • it lives on the base State class itself

The paired-setter case is also the opt-out: use get/set pairs for native accessor semantics, a bare getter for derived data.

Errors in getters

A computed that throws gets complained about, loudly:

  • On the first compute, the error is logged as a warning and rethrown to whoever read it.
  • On a recompute (a dependency changed), it's logged to the console - and the previous cached value is kept. Readers don't throw.

Effects

For side effects (logging, integrations, manual work), use state.get(effect):

const stop = state.get((current, changed) => {
  console.log('value is now:', current.value);
  return () => console.log('about to re-run or be destroyed');
});

The effect runs immediately, then re-runs whenever a tracked property changes. It returns an unsubscribe function.

What the effect callback receives

  • current - a tracking proxy of the state. Reads subscribe.
  • changed - a readonly array of keys changed since the last run. Empty on the first run - or undefined when that first run was deferred, because the effect was registered before the state was ready. (Registering effects during construction is fine; they simply wait for activation.)

What the effect can return

  • () => void - a cleanup function. Called on re-run, unsubscribe, or destruction. Its argument tells you which:
    • true - about to re-run (a dependency changed)
    • false - manually cancelled (someone called the returned stop)
    • null - state destroyed
  • null - cancel the effect after one run.
  • Promise<void> - ignored.
  • void - no cleanup.

An effect that writes to a property it also reads won't re-trigger itself in the same cycle - the write lands in the next batch. Handy for normalize-on-change, without the infinite loop.

Effects in new()

A common pattern is to register an effect during initialization:

class Session extends State {
  userId = set<string>();
  activity: string[] = [];

  new() {
    return this.get((current) => {
      log(`user ${current.userId} activity:`, current.activity);
    });
  }
}

Returning the unsubscribe function from new() ties the effect's lifetime to the state's lifetime.


Listening for a single key

If you only care about one property, set takes a key and a callback:

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

This fires on every assignment that changes the value, and on explicit state.set('count') dispatches. It's a plain listener, not an effect - no tracking proxy involved. Return null from the callback to unsubscribe after one firing.


Events

Underneath, everything above is one event stream. Property updates are just the common case:

EventMeaning
string | number | symbolProperty updated, or a custom event
trueReady - fired once at activation
falseUpdate flush completed
nullDestroyed - terminal

state.set(callback) hears the key events; state.set(null, callback) hears destruction. The bare true/false signals belong to the observable layer beneath - listener from @expressive/mvc/observable receives them raw, though most code never needs to go that deep. See API: Utilities.

Custom events

Dispatching a key that isn't a property is a custom event. Pair set(event) with set(event, callback):

class Uploader extends State {
  progress = 0;

  finish() {
    this.progress = 100;
    this.set('!finished');
  }
}

const upload = Uploader.new();

upload.set('!finished', () => {
  confetti();
});

Symbols work too, and can't collide with property names. If you use strings, a prefix ('!finished') or dash-case ('my-event') keeps you out of the property namespace.


Batching

All writes in a synchronous block coalesce into a single flush:

submit() {
  this.name = this.name.trim();
  this.email = this.email.toLowerCase();
  this.submitted = true;
  // One flush at the end of the current tick.
  // Effects re-run once, components re-render once.
}

The flush happens via queueMicrotask. If you need to wait for it:

await state.set();
// All pending updates have flushed.
// The resolved value is an array of keys that changed.

Equal values are discarded. Writing this.count = this.count does nothing.


Suspense

When a tracked read hits a property that hasn't resolved yet (an unset set<T>() placeholder, a pending async factory), it throws a suspense-compatible Promise. React catches it and shows the nearest <Suspense> fallback. When the value resolves, the read retries.

class UserProfile extends State {
  userId = set<string>(); // required - throws until assigned
  user = set(async () => {
    const res = await fetch(`/api/users/${this.userId}`);
    return res.json();
  });
}

function Profile() {
  const { user } = UserProfile.get(); // suspends until loaded
  return <h1>{user.name}</h1>;
}

Inside an effect, a suspense throw pauses the effect - it retries when the promise resolves, and while paused, updates to its other tracked properties don't re-run it. This works the same whether the consumer is React or a plain effect.

See Async for the full story.


Silent reads

Sometimes you need a value but don't want a re-render or re-run when it changes. Read through is:

state.get((current) => {
  const v = current.trackedValue; // subscribes
  const t = current.is.timestamp; // silent - does not subscribe
});

A common use: reading a value you're about to overwrite, without re-triggering the effect that overwrote it.


A word on closures

The React hook model makes you think about closures constantly - stale references, dependency arrays, useCallback, useMemo. Expressive sidesteps this by reading from this at the time of access:

async save() {
  // `this.name` and `this.email` are read NOW, not when save was created.
  await api.update({ name: this.name, email: this.email });
}

There are no stale closures because there are no closures. Every method call reads from the live instance.


Next

  • Arrays & Collections - mutation, hot(), and per-index tracking.
  • Async - async factories, Suspense, error handling.
  • Components - the Component class, where state renders itself.

On this page