Skip to content

Actor state

MatrixActor carries observable state. Updating it via setState() emits $stateChanged and per-key $propertyChanged events. UI shells subscribe to these and re-render. Persistence (across sessions, across processes, across federation) is governed by static stateScope.

The state object

Every actor has a _state field initialized to {} (MatrixActor.ts:563). It is an unordered record of values. Writes go through setState; reads go through the public get state() getter.

ts
class Counter extends MatrixActor {
  protected async onBootstrap() {
    this.setState({ count: 0 });
  }

  onIncrement() {
    const next = (this.state.count as number) + 1;
    this.setState({ count: next });
  }
}

this.state returns a Readonly<Record<string, unknown>>. Mutating it directly does not trigger change events — always go through setState.

setState(patch) semantics

setState(patch) (MatrixActor.ts:1714-1742) does the following:

  1. Snapshot the previous state.
  2. Object.assign(this._state, patch) — shallow merge. Nested objects are replaced wholesale, not deep-merged.
  3. Emit $stateChanged with { state, stateScope, sessionId? }.
  4. For every key in the patch whose value actually changed, emit $propertyChanged with { propertyName, oldValue, newValue }.
  5. Call this._shellUpdateCallback if a shell has registered one (drives reactive UI rerender).
ts
this.setState({ count: 1 });
// emits: $stateChanged with state.count = 1
// emits: $propertyChanged with propertyName='count', oldValue=0, newValue=1

Caution: Only top-level keys are diffed. setState({ user: { name: 'A' } }) followed by setState({ user: { name: 'B' } }) always emits $propertyChanged for user because it's a different object reference, regardless of whether the value compares equal by deep equality. If you need deep diffing, you compute it yourself before calling setState.

setState throws if called before initialize() — there is no context, so emit would fail.

Subscribing to state changes

From within the same actor, listen by overriding the framework's hook. From outside, subscribe to the $events topic and filter for the $stateChanged op.

ts
// Outside subscriber
const eventsTopic = TopicRouter.events('counter');
transport.subscribe(eventsTopic, (payload, meta) => {
  const env = payload as MxEnvelope;
  if (env.op === '$stateChanged') {
    console.log('counter state:', env.payload);
  }
});

MatrixRuntime.onEvent(mount, event, handler) is the convenience wrapper (MatrixRuntime.ts:810-822).

$getState — pull the current state

Every actor handles $getState (system op, MatrixActor.ts:867-871). It returns:

ts
{ state: this._toSerializable(this._state) }

_toSerializable strips functions, replaces non-clonable values with sentinels, so the response is JSON-safe.

ts
import { RequestReply } from '@open-matrix/core';

const { state } = await RequestReply.request<{ state: Record<string, unknown> }>(
  transport, 'counter', '$getState', {},
);

Persistence scope (static stateScope)

static stateScope (MatrixActor.ts:303-316) declares how broadly state should be persisted. The default is 'session' — survives an F5 reload but not a new browser session.

ScopeMeaning
sessionPer-session. Default. Survives F5; lost on new tab/session.
userPer-user. Survives new sessions. Use for preferences.
pagePer-page/tab. Scoped to a single browser page.
workspacePer-workspace. Shared across workspace members.
orgPer-organization. Company defaults.
rootPer-root. Root-wide shared state.
realmPer-realm (legacy alias for root).
globalCross-root. Federation-wide.

The framework does not implement persistence directly. The BlackboardActor (in @open-matrix/blackboard) and NatsStateTap (in @open-matrix/state-plane) read stateScope and the $stateChanged events to decide what KV bucket and key prefix to write to. From your actor's point of view, you set static stateScope and call setState() — persistence and cross-process sync are arranged by infrastructure.

ts
class Preferences extends MatrixActor {
  static stateScope: 'user' = 'user';

  async onPrefSet(payload: { key: string; value: unknown }) {
    this.setState({ [payload.key]: payload.value });
    // → $stateChanged is observed by NatsStateTap, written to user-scoped KV
  }
}

Disabling persistence (static persistState)

static persistState: boolean = true (MatrixActor.ts:319-322). Set to false for ephemeral actors that should never be tapped to KV — loading spinners, animations, transient UI bookkeeping.

ts
class Spinner extends MatrixActor {
  static persistState = false;
  // setState calls still work, $stateChanged still fires for UI;
  // KV taps skip writes for this actor's mount.
}

State vs static state vs static streams

These are three different things despite the similar names:

ConceptWhat it meansWhere it's stored
this.state (instance field)The actor's live observable state.In-process, on the actor instance.
static state (declaration)Declared key-value state surface this actor publishes.Declaration only — describes what state keys agents/UI should expect.
static streams (declaration)Replayable event history this actor produces.Declaration only — backed by JetStream streams when present.

static state and static streams are part of the Actor Communication Contract. They are documentation/discovery hooks for $introspect. The state plane (KV writes/reads/watches) is wired by infrastructure based on those declarations.

Worked example: a tracked-shopping cart

ts
import { MatrixActor } from '@open-matrix/core';

interface CartLine { sku: string; qty: number; price: number; }

class Cart extends MatrixActor {
  static stateScope: 'user' = 'user';

  static accepts = {
    'cart.add': {
      description: 'Add a line item to the cart.',
      schema: {
        sku: { type: 'string', description: 'Product SKU' },
        qty: { type: 'number', description: 'Quantity' },
        price: { type: 'number', description: 'Price in cents' },
      },
      returns: { lines: { type: 'number', description: 'Total distinct lines' } },
    },
    'cart.clear': {
      description: 'Empty the cart.',
      schema: {},
      returns: {},
    },
  };

  static emits = {};

  protected async onBootstrap() {
    this.setState({ lines: [] as CartLine[], total: 0 });
  }

  onCartAdd(payload: CartLine) {
    const lines = [...(this.state.lines as CartLine[]), payload];
    const total = lines.reduce((s, l) => s + l.qty * l.price, 0);
    this.setState({ lines, total });
    return { lines: lines.length };
  }

  onCartClear() {
    this.setState({ lines: [], total: 0 });
    return {};
  }
}

Each call to setState emits $stateChanged carrying the full state object plus stateScope: 'user'. A KV-tap actor watching that scope writes the snapshot under the user's bucket; the next session of the same user sees lines and total already populated when the cart actor mounts.

See also

Source: projects/matrix-3/packages/core/src/core/MatrixActor.ts:1714-1742 (setState), :867-871 (on$getState), :303-322 (stateScope, persistState).