Skip to content

Actor-Web adapter

Use ignite-element/actor-web when an Actor-Web runtime already owns orchestration, supervision, sequencing, transport, and source lifecycle. Ignite stays in the projection/read-model role: it turns Actor-Web snapshots into render state and exposes explicit commands, without taking over the runtime.

This is the opposite split from ignite-element/xstate, where Ignite owns the local machine. Reach for Actor-Web when the source is long-lived and lives outside the element; reach for XState (or Redux/MobX) when the element owns its own state.

Actor-Web sources come in two shapes, both typed by a shared Context, Message, and (optionally) Emitted:

  • Read-model source (ActorWebReadModelSource) — the projection Ignite renders from. It exposes snapshot() and subscribe(...); it does not send messages.
  • Command source (ActorWebCommandSource) — a read-model source plus send(message) and an optional ask(message) for request/response.

Either way you pass a single handle as source — the same one-source shape every Ignite adapter uses. When the source is command-capable, commands issue actor.send/actor.ask; a read-only source renders but can’t send. igniteCore infers the projection and message types from the source — you never pass type arguments:

import { igniteCore } from 'ignite-element/actor-web';
import type { ActorWebCommandSource } from 'ignite-element/actor-web';
type ShipmentProjection = {
shipmentId: string;
status: 'queued' | 'packed' | 'shipped';
etaLabel: string | null;
canCancel: boolean;
};
type ShipmentRequest =
| { type: 'shipment.refresh'; shipmentId: string }
| { type: 'shipment.cancel'; shipmentId: string }
| { type: 'shipment.label.request'; shipmentId: string };
// Your Actor-Web runtime hands Ignite a typed, command-capable source handle.
declare const shipmentSource: ActorWebCommandSource<
ShipmentProjection,
ShipmentRequest
>;
export const shipmentCard = igniteCore({
source: shipmentSource,
view: ({ context }) => ({
shipmentId: context.shipmentId,
status: context.status,
etaLabel: context.etaLabel,
canCancel: context.canCancel,
}),
commands: ({ actor }) => ({
refresh: (shipmentId: string) =>
actor.send({ type: 'shipment.refresh', shipmentId }),
cancel: (shipmentId: string) =>
actor.send({ type: 'shipment.cancel', shipmentId }),
}),
});

ShipmentProjection flows into the view context, and ShipmentRequest flows into actor.send, all inferred from shipmentSource — no igniteCore<...> annotations. The projected context carries business/read-model state; updating it stays Actor-Web’s job. Ignite sends requests back and re-renders when the next projection snapshot arrives.

Your Actor-Web runtime owns the sources and hands Ignite ready-made handles — Ignite never constructs them, and ignite-element core has no dependency on actor-web (the binding lives only in the optional ignite-element/actor-web entrypoint). A runtime topology typically exposes one source factory per shape; each maps to an igniteCore key:

Actor-Web source factoryHands youPass to igniteCore as
readModel(options)a read-only projection (snapshot/subscribe, no send)source
commandSource(options)a read model plus send/asksource

options is connection config, not actor identity — the gateway URL, auth, scope, and transport ({ gateway: { url, scope?, auth? }, … }). The actor is already selected by the topology path. Refer to your Actor-Web runtime’s source/topology API for the exact factory signatures.

Ignite takes a single handle as source — the same one-source shape as every other adapter, with no separate command channel:

  • Command-capable source — exposes send/ask, so commands can dispatch and actor.ask works.
  • Read-only source — renders fine, but command dispatch is a no-op. Use a command-capable source whenever the component issues commands.

Actor-Web’s own model can still split reads from writes internally; Ignite just consumes whichever single handle the runtime hands it.

Commands cross the boundary through explicit requests on the command actor:

  • actor.send(message) — fire-and-forget; returns a Promise that resolves when the runtime accepts the message. Use it for state-changing requests.
  • actor.ask(message, timeout?) — request/response. It is optional and only exists on sources that support it, so call it with optional chaining and a typed response.

A command-capable source is passed as source — the same handle drives both rendering and commands:

import { igniteCore } from 'ignite-element/actor-web';
import type { ActorWebCommandSource } from 'ignite-element/actor-web';
type ShipmentProjection = { shipmentId: string; canCancel: boolean };
type ShipmentRequest =
| { type: 'shipment.cancel'; shipmentId: string }
| { type: 'shipment.label.request'; shipmentId: string };
declare const shipmentSource: ActorWebCommandSource<
ShipmentProjection,
ShipmentRequest
>;
export const shipmentActions = igniteCore({
source: shipmentSource,
commands: ({ actor }) => ({
cancel: (shipmentId: string) =>
actor.send({ type: 'shipment.cancel', shipmentId }),
requestLabel: (shipmentId: string) =>
actor.ask?.<{ url: string }>({
type: 'shipment.label.request',
shipmentId,
}),
}),
});

Never mutate the projected read model to “send” — always go through send/ask.

Each projection snapshot carries transport metadata describing the runtime connection. Opt into it explicitly through the transport field on the view callback — most components should stay focused on business state.

transport is an ActorWebTransportStatus: { state, updatedAt, lastSequence?, lagMs?, reason? }. The state is one of:

transport.stateMeaning
localno remote transport; purely local
connectedlive and in sync
replayingcatching up on buffered history
degradedconnected but lagging or partial (reason may explain)
disconnectedtransport lost
import { igniteCore } from 'ignite-element/actor-web';
import type { ActorWebReadModelSource } from 'ignite-element/actor-web';
type ShipmentProjection = { shipmentId: string; status: string };
type ShipmentRequest = { type: 'shipment.refresh'; shipmentId: string };
declare const shipmentReadModel: ActorWebReadModelSource<
ShipmentProjection,
ShipmentRequest
>;
export const shipmentBadge = igniteCore({
source: shipmentReadModel,
view: ({ context, transport }) => ({
shipmentId: context.shipmentId,
status: context.status,
syncState: transport.state,
degradedReason:
transport.state === 'degraded' ? transport.reason ?? null : null,
}),
});

Use that pattern for connection badges, replay banners, or diagnostics — not as a default field on every view.

Actor-Web sources can carry a third type parameter, Emitted — the union of domain events the actor publishes on its event side-channel (the source’s optional subscribeEvent). When a source declares it, the adapter bridges those events into the headless runtime automatically: they surface through on(type), execute().events, and record() with no events: declaration and no effects: callback — and the event names and payloads are typed from the source’s union, with no manual type arguments.

import { igniteCore } from 'ignite-element/actor-web';
import type { ActorWebCommandSource } from 'ignite-element/actor-web';
type ShipmentProjection = { shipmentId: string; status: string };
type ShipmentRequest = { type: 'shipment.cancel'; shipmentId: string };
type ShipmentEmitted =
| { type: 'SHIPMENT_CREATED'; shipmentId: string }
| { type: 'SHIPMENT_CANCELLED'; shipmentId: string; reason: string };
declare const shipmentSource: ActorWebCommandSource<
ShipmentProjection,
ShipmentRequest,
ShipmentEmitted
>;
export const shipmentFeed = igniteCore({
source: shipmentSource,
view: ({ context }) => ({ status: context.status }),
commands: ({ actor }) => ({
cancel: (shipmentId: string) =>
actor.send({ type: 'shipment.cancel', shipmentId }),
}),
});
// on(type) — the handler's `detail` IS the emitted event object.
const subscription = shipmentFeed.on('SHIPMENT_CANCELLED', (event) => {
console.log(event.detail.reason);
});
subscription.unsubscribe();
// execute().events — emits captured during the command window, each as
// { type, payload } where payload is the emitted event object.
const result = await shipmentFeed.execute('cancel', 'shipment-1');
for (const entry of result.events) {
if (entry.type === 'SHIPMENT_CANCELLED') {
console.log(entry.payload.reason);
}
}

How the bridge behaves:

  • Uniform shape. An emitted member M surfaces as { type: M.type, payload: M } in execute().events and record() summaries, and as event.detail === M in on(...) handlers — the same shape declared events use.
  • Command-window capture. execute() collects the emits that arrive between command start and the post-command flush; record() inherits them through execute. Emits outside a command window still reach on(...) listeners, which stay subscribed for their lifetime.
  • Leak-free. Each on(...) subscription and each execute() call manages its own source subscription and releases it on unsubscribe() / command completion.
  • Effects stay additive. events: + effects: remain the way to emit UI-derived events (computed from snapshot changes); source-emitted events need neither. Both flow side by side into the same execute().events stream and on(...) surface.

Sources without an event side-channel simply omit subscribeEvent (and Emitted defaults to the message type) — the bridge no-ops and everything else works unchanged.

  • ignite-element/actor-web — an Actor-Web runtime already owns orchestration and long-lived sources; Ignite projects and sends explicit requests.
  • ignite-element/xstate — Ignite owns local behavior; the machine is the element’s state source and Ignite manages its lifecycle.

See Choosing xstate vs actor-web for the boundary rationale, and Redux & MobX for the other two adapters.