Expressive State
Guides

State Classes

Defining properties, methods, and lifecycle on State

The State class is the basic building block of Expressive. Everything else - reactivity, components, context, async - builds on top of a class that extends State.

import State from '@expressive/react';

class Counter extends State {
  count = 0;

  increment() {
    this.count++;
  }
}

Instantiation

Always create instances via 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. Counter.new() runs the constructor, initializes reactive properties, executes any constructor arguments, and fires the new() lifecycle hook.

Inside React, Counter.use() handles activation automatically. Outside React, always use .new().

Initial values and callbacks

.new() accepts objects (initial values), functions (lifecycle callbacks), and arrays (nested):

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. Assigning to them triggers batched updates:

class App extends State {
  name = 'World';
  count = 0;
}

const app = App.new();
app.name = 'Alice'; // notifies subscribers
app.count += 1;     // notifies subscribers

All writes to the same state in the same tick are batched into one flush (via queueMicrotask). If the new value is === to the previous value, no event is emitted.

Non-reactive properties

If you need to stash something on the instance without making it reactive, assign in new() or use ref:

class Thing extends State {
  element = ref<HTMLDivElement>(); // mutable, not enumerable, not reactive in renders

  new() {
    // Assigning here *after* activation still creates a reactive property
    // To keep something non-reactive, use ref, def, or just store on a closure.
  }
}

For the common case - holding a DOM element or a mutable handle - 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).
  • super calls 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 a deliberate rule: methods are "actions", not "observations". If you want an effect to re-run when a method's inputs change, read those properties via 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 { count, is } = Counter.use();
is.count = 5; // works

Destructuring breaks the binding between a local variable and the property, so count = 5 just reassigns the local. is gives you back a reference to the live instance.

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 useful when you need a value at a point in time but don't want a change to that value to trigger a re-run.


Lifecycle

A State class has four phases:

PhaseTriggerWhat happens
Constructionnew MyState()Fields are set to their initial values, no reactivity yet
ActivationState.new()Properties become managed, constructor args run, new() hook fires
OperationProperty assignmentBatched updates flush via microtask, effects re-run
Destructionstate.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 all properties are initialized and constructor arguments have been processed. Inside new(), everything on this is live - you can assign, read, call methods, and register effects.

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 that 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 explicitly. When destruction runs:

  1. Children are destroyed first, inner-to-outer.
  2. Listeners are notified with a null signal.
  3. Effect cleanups run with their argument set to null.
  4. The new() hook's returned cleanup is called.
  5. 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 null destroys 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). 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 enumerable properties:

for (const [key, value] of state) {
  console.log(key, value);
}

The static class is also iterable - it yields its own constructor and its ancestors, stopping before the base State:

for (const Ctor of MyState) {
  console.log(Ctor.name);
}

Next

  • Reactivity - tracking, computed values, and batching.
  • Components - the Component class for self-rendering state.
  • API: State - every method and static on the base class.

On this page