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.
Ownership model
Section titled “Ownership model”- If Ignite creates it, Ignite owns it.
- If you pass it in, you own it.
cleanupis 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.
Adapters
Section titled “Adapters”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.
Adapter shapes
Section titled “Adapter shapes”- 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
xstatevsactor-weband the Actor-Web guide.
Source factory adapter inference
Section titled “Source factory adapter inference”The default igniteCore entrypoint does not execute zero-argument source factories while inferring an adapter.
- Zero-argument Redux and MobX factories require an explicit
adapteror an adapter-specific entrypoint such asignite-element/reduxorignite-element/mobx. - Zero-argument and defaulted Actor-Web factories require
adapter: "actor-web"or theignite-element/actor-webentrypoint. - Only required host-context Actor-Web factories are omitted-adapter inferable, because they declare a runtime parameter without a default value.
Shared vs. isolated
Section titled “Shared vs. isolated”- 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: trueto 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();Projection (view)
Section titled “Projection (view)”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, events, and effects
Section titled “Commands, events, and effects”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.
Putting it together
Section titled “Putting it together”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>));Deterministic effects
Section titled “Deterministic effects”Effect timing is deterministic, which is what makes the headless runtime and testing DSL reproducible:
- Attaching a host or headless runtime seeds
prevSnapshotfrom 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.
Lifecycle at a glance
Section titled “Lifecycle at a glance”- Isolated (a machine Ignite creates): a per-element adapter, stopped on disconnect;
cleanupdoesn’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: trueto 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-webwhen 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.
Renderers and configuration
Section titled “Renderers and configuration”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.
Design principles
Section titled “Design principles”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.