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.
Read-only vs command-capable sources
Section titled “Read-only vs command-capable sources”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 exposessnapshot()andsubscribe(...); it does not send messages. - Command source (
ActorWebCommandSource) — a read-model source plussend(message)and an optionalask(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.
Where sources come from
Section titled “Where sources come from”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 factory | Hands you | Pass to igniteCore as |
|---|---|---|
readModel(options) | a read-only projection (snapshot/subscribe, no send) | source |
commandSource(options) | a read model plus send/ask | source |
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.
One source surface
Section titled “One source surface”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, socommandscan dispatch andactor.askworks. - 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.
send vs ask
Section titled “send vs ask”Commands cross the boundary through explicit requests on the command actor:
actor.send(message)— fire-and-forget; returns aPromisethat 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.
Transport status
Section titled “Transport status”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.state | Meaning |
|---|---|
local | no remote transport; purely local |
connected | live and in sync |
replaying | catching up on buffered history |
degraded | connected but lagging or partial (reason may explain) |
disconnected | transport 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.
Emitted events
Section titled “Emitted events”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
Msurfaces as{ type: M.type, payload: M }inexecute().eventsandrecord()summaries, and asevent.detail === Minon(...)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 throughexecute. Emits outside a command window still reachon(...)listeners, which stay subscribed for their lifetime. - Leak-free. Each
on(...)subscription and eachexecute()call manages its own source subscription and releases it onunsubscribe()/ 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 sameexecute().eventsstream andon(...)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.
When to use which entrypoint
Section titled “When to use which entrypoint”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.