Appearance
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:
- Snapshot the previous state.
Object.assign(this._state, patch)— shallow merge. Nested objects are replaced wholesale, not deep-merged.- Emit
$stateChangedwith{ state, stateScope, sessionId? }. - For every key in the patch whose value actually changed, emit
$propertyChangedwith{ propertyName, oldValue, newValue }. - Call
this._shellUpdateCallbackif 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=1Caution: Only top-level keys are diffed.
setState({ user: { name: 'A' } })followed bysetState({ user: { name: 'B' } })always emits$propertyChangedforuserbecause 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 callingsetState.
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.
| Scope | Meaning |
|---|---|
session | Per-session. Default. Survives F5; lost on new tab/session. |
user | Per-user. Survives new sessions. Use for preferences. |
page | Per-page/tab. Scoped to a single browser page. |
workspace | Per-workspace. Shared across workspace members. |
org | Per-organization. Company defaults. |
root | Per-root. Root-wide shared state. |
realm | Per-realm (legacy alias for root). |
global | Cross-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:
| Concept | What it means | Where 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
- Actor lifecycle — when state is initialized and disposed.
- Actor ops —
$getState,$getHistorysystem ops. - Actor sessions — how
sessionIdinteracts with$stateChangedpayloads.
Source:
projects/matrix-3/packages/core/src/core/MatrixActor.ts:1714-1742(setState),:867-871(on$getState),:303-322(stateScope,persistState).