Skip to content

The Ignite model

Ignite Element separates state from rendering. Your state lives in a source (an XState machine, a Redux store, a MobX observable, or an Actor-Web runtime); an adapter subscribes to it and hands Ignite snapshots; you project those snapshots into render props and expose commands. One igniteCore registration yields one custom element, and composition happens through HTML.

The whole model is four ideas: who owns the source, how the adapter bridges it, how you project state into a view, and how commands, events, and effects move intent and consequences across the element boundary.

  • If Ignite creates it, Ignite owns it.
  • If you pass it in, you own it.
  • cleanup is a shared-adapter teardown override; most components don’t need it.

This single rule decides teardown: Ignite stops the sources it created and only releases the subscriptions it added to sources you passed in.

In ignite-based apps, adapters isolate IO and environment effects (wallets, sockets, storage, network). Machines describe what should happen; adapters describe how the world responds. Adapters are wired internally by helper constructors (for example, the create*Actor entrypoints in ecosystem packages) — you don’t manually attach adapters or embed IO inside machines.

Keep your state source pure and deterministic. Put state structure, transitions, guards, and domain events in the machine; keep network calls, browser APIs (window, localStorage, fetch), and long-running IO out of it. Pure sources are easier to test, reuse, and reason about, and they compose cleanly with adapters that own the IO.

  • XState — pass a machine definition or a started actor. Ignite starts and stops machine-backed actors it owns; a pre-started actor you pass is subscribed to but never stopped for you.
  • Redux Toolkit — pass a slice factory or a store instance. Ignite subscribes and reads state on change; a store you own is never disposed.
  • MobX — pass an observable or factory. Ignite reads derived values when observables mutate; externally created observables keep running.
  • Actor-Web — project a long-lived runtime that already owns orchestration. See Choosing xstate vs actor-web and the Actor-Web guide.

The default igniteCore entrypoint does not execute zero-argument source factories while inferring an adapter.

  • Zero-argument Redux and MobX factories require an explicit adapter or an adapter-specific entrypoint such as ignite-element/redux or ignite-element/mobx.
  • Zero-argument and defaulted Actor-Web factories require adapter: "actor-web" or the ignite-element/actor-web entrypoint.
  • Only required host-context Actor-Web factories are omitted-adapter inferable, because they declare a runtime parameter without a default value.
  • Isolated (default) — Ignite constructs a new adapter per element instance. Great for local state; stopped on disconnect (for XState, the Ignite-created actor is stopped too).
  • Shared — pass a long-lived actor/store/observable. Because you own that source, Ignite keeps the shared adapter alive for the core’s lifetime; element disconnects don’t tear it down, so an outlet swapping elements never freezes the shared state. Ignite never stops or closes a source you provided. Set cleanup: true to opt back into reference-counted teardown of the adapter when the last element disconnects.
import { createActor } from 'xstate';
import { igniteCore } from 'ignite-element/xstate';
import { toggleMachine } from './machine';
const sharedActor = createActor(toggleMachine);
sharedActor.start();
const register = igniteCore({
source: sharedActor,
// ...view/commands/events
});
register('ignite-toggle', /* renderer */);
// Later, when you no longer need the shared actor:
sharedActor.stop();

The projection is explicit: you decide which snapshot fields become render values and commands. Use the view callback to map raw snapshots into render-friendly data. It runs on access and reflects the latest adapter snapshot; it is not memoized, so keep it fast.

view: ({ context, can }) => ({
count: context.count,
canUndo: can?.({ type: 'UNDO' }) ?? false,
})

Commands express intent; effects express consequences. Commands are the UI → behavior boundary: keep them small and deterministic, send events to your source, and avoid IO (it belongs in adapters). They receive { actor, host } (plus store/observable for those adapters).

commands: ({ actor }) => ({
increment: () => actor.send({ type: 'INCREMENT' }),
});

Declare events to type emit, then fire DOM CustomEvents from effects. Events bubble and are composed by default, so host frameworks can listen with addEventListener. Prefer names whose casing maps to Ignite JSX handler props (onEventName).

events: (event) => ({
incremented: event<{ count: number }>(),
}),
commands: ({ actor }) => ({
increment: () => {
actor.send({ type: 'INCREMENT' });
},
}),
effects: ({ snapshot, prevSnapshot, emit }) => {
if (snapshot.context.count === prevSnapshot.context.count) return;
emit('incremented', { count: snapshot.context.count });
},

Listen in plain DOM with element.addEventListener('incremented', …), or in Ignite JSX with <my-counter onIncremented={(event) => handleIncrement(event.detail)}></my-counter>.

host is the custom element instance — think of it like a DOM ref. Use it for small imperative affordances (focus, measuring, dispatching extra DOM events); keep state in your adapter and rendering declarative.

import { igniteCore } from 'ignite-element/xstate';
import machine from './counter-machine';
const component = igniteCore({
source: machine,
events: (event) => ({
incremented: event<{ count: number }>(),
}),
view: ({ context }) => ({
count: context.count,
canDecrement: context.count > 0,
}),
commands: ({ actor }) => ({
increment: () => {
actor.send({ type: 'INCREMENT' });
},
decrement: () => actor.send({ type: 'DECREMENT' }),
}),
effects: ({ snapshot, emit, select }) => {
const count = select((state) => state.context.count);
if (!count.changed) return;
emit('incremented', { count: snapshot.context.count });
},
});
component('my-counter', ({ count, canDecrement, increment, decrement }) => (
<div class="counter">
<button onClick={decrement} disabled={!canDecrement}>-</button>
<span>{count}</span>
<button onClick={increment}>+</button>
</div>
));

Effect timing is deterministic, which is what makes the headless runtime and testing DSL reproducible:

  • Attaching a host or headless runtime seeds prevSnapshot from the current adapter state. Ignite does not replay historical transitions to newly attached consumers.
  • The first subscription notification establishes that baseline and does not run effects(...).
  • After that, effects(...) runs once per adapter update in a stable order.
  • For mounted custom elements, effects attach before the render subscription, so events emitted from a transition fire before the next render for that same host.
Ignite Element data flowA state source feeds an adapter, which produces snapshots that the view projects into render props for a custom element. Commands send intent back to the source, and effects emit typed DOM events outward.State sourcemachine / storeIgnite adaptersubscribe · snapshotview()render props<custom-element>snapshotprojectrendercommands → actor.send(intent)effects → emit typed DOM events
  • Isolated (a machine Ignite creates): a per-element adapter, stopped on disconnect; cleanup doesn’t apply.
  • Shared (an actor/store/observable you pass): one adapter, kept alive for the core’s lifetime by default since you own the source; element disconnects don’t release it (set cleanup: true to opt into reference-counted teardown). User-owned sources are never stopped.

Choosing ignite-element/xstate vs ignite-element/actor-web

Section titled “Choosing ignite-element/xstate vs ignite-element/actor-web”
  • Use ignite-element/xstate (or Redux/MobX) when Ignite owns local behavior for the element — the source is the element’s state and Ignite manages its lifecycle.
  • 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 and crosses the boundary through explicit requests (actor.send / actor.ask), never by mutating the projected read model.

The full Actor-Web walkthrough — read-model vs command sources, send vs ask, and transport status — lives in the Actor-Web guide.

Ignite JSX is the config-free default; keep component CSS in local <style> tags. Project-wide renderer selection (including the legacy lit renderer), the ignite.config.ts file, and shared styles all live in Advanced config.

Keep the boundaries clean: the adapter owns the state/actor model, the renderer stays declarative, and host is a small imperative DOM bridge used only when needed. Avoid IO inside machines, direct browser APIs in machines, and manual adapter wiring in components — those belong in adapters wired by helper constructors, not in machines or UI code.