Share a source and own its lifetime
The flow is source → native snapshot → states → renderer view.
Pass the same source instance to a core to share its state across views.
Each application process owns its instances.
Sharing code does not synchronize devices.
For observation and terminal disposal, see Ownership & cleanup.
Two JSX views, one source
Section titled “Two JSX views, one source”/** @jsxImportSource ignite-element/jsx */import { igniteCore } from "ignite-element/xstate";import { assign, createActor, createMachine } from "xstate";
const machine = createMachine({ context: { count: 0 }, on: { INCREMENT: { actions: assign({ count: ({ context }) => context.count + 1 }), }, },});export const source = createActor(machine).start();export const core = igniteCore({ source, states: (snapshot) => ({ count: snapshot.context.count, canIncrement: snapshot.can({ type: "INCREMENT" }), }), commands: ({ source: actor }) => ({ increment: () => actor.send({ type: "INCREMENT" }), }),});core("shared-counter", (ctx) => ( <button type="button" disabled={!ctx.canIncrement} onClick={() => ctx.increment()} > Count: {ctx.count} </button>));core("shared-counter-summary", (ctx) => <p>Count: {ctx.count}</p>);// The application owns source.stop(); navigation only attaches/detaches views.Run this complete example from examples/adapters/xstate/shared-counter.html.
The core and source are created at application bootstrap, not in a renderer.
Shared Redux store
Section titled “Shared Redux store”Create the store once and pass the instance to igniteCore.
Save this complete example as redux.ts:
import { configureStore, createSlice } from "@reduxjs/toolkit";import { igniteCore } from "ignite-element/redux";
const slice = createSlice({ name: "counter", initialState: { count: 0 }, reducers: { increment: (state) => { state.count += 1; }, },});export const source = configureStore({ reducer: slice.reducer });export const core = igniteCore({ source, states: (snapshot) => ({ count: snapshot.count }), commands: ({ source: store }) => ({ increment: () => store.dispatch(slice.actions.increment()), }),});Views using this core observe the same store.
Shared MobX observable
Section titled “Shared MobX observable”Create the observable once and pass the instance to igniteCore.
Save this complete example as mobx.ts:
import { igniteCore } from "ignite-element/mobx";import { makeAutoObservable } from "mobx";
class Counter { count = 0; constructor() { makeAutoObservable(this); } increment() { this.count += 1; }}export const source = new Counter();export const core = igniteCore({ source, states: (snapshot) => ({ count: snapshot.count }), commands: ({ source: store }) => ({ increment: () => store.increment() }),});Views using this core observe the same counter.
Own the source lifetime
Section titled “Own the source lifetime”| Source input | Ownership |
|---|---|
| Existing XState actor | Application-owned; Ignite releases only its observation resources |
| XState machine definition | Existing Ignite acquisition creates private isolated actors and owns their release |
| Existing Redux store or MobX observable | Application-owned behavior, using ignite-element/redux or ignite-element/mobx and native operations |
The root igniteCore() is source-free and registrar-only.
Navigation removes observers, not the feature’s source.
Source-native sign-in, sign-out and account reset can keep one core and its registrations.
When a feature or session permanently discards its core, dispose the core.
Stop a borrowed actor only if that owner also created the actor and nothing else needs it:
This example shows the release order:
import { createActor, createMachine } from 'xstate';import { igniteCore } from 'ignite-element/xstate';
const machine = createMachine({});const source = createActor(machine).start();const core = igniteCore({ source });try { core.dispose();} finally { source.stop();}The core releases its observations; the application stops its actor.
Repeated core disposal does not release resources again.
The application must release any observations it created separately.
React and React Native
Section titled “React and React Native”/** @jsxImportSource react */import { useIgnite } from "ignite-element/react";import { core } from "./counter-core";import "./counters.css";
export function Counter() { const ctx = useIgnite(core); return ( <section className="counter-card" aria-label="Shared counter"> <p> {ctx.label}: <output aria-label="Count">{ctx.count}</output> </p> <div className="counter-controls"> <button type="button" onClick={() => ctx.decrement()}> Decrement </button> <button type="button" disabled={!ctx.canIncrement} onClick={() => ctx.increment()} > Increment </button> </div> <label> Counter label <input value={ctx.label} onChange={(event) => ctx.setLabel(event.target.value)} /> </label> </section> );}export function SharedCounters() { return ( <div className="ignite-counter-demo"> <div className="counter-grid"> <Counter /> <Counter /> </div> </div> );}Shared cores are ready after owner-controlled construction.
Unmounting a view releases its subscription, not the core.
Each core/source instance has one effect evaluator.
It starts on legitimate runtime use or a committed subscription or element connection.
It establishes a baseline, then queues delivery after source processing.
The activated shared evaluator remains active until core disposal, even with no connected views.
Delivery does not guarantee a renderer update or framework commit.
See delivery and ownership for the canonical activation, lifetime, disposal, synchronous-void and error rules.
Source-owned account and receipt behavior
Section titled “Source-owned account and receipt behavior”The synthetic preference source owns account identity and a generation.
Sign-out clears private state and exits its invocation; an old intent carries an old generation and is rejected.
A new account uses the same actor/core, not rebinding.
import { assign, fromPromise, setup } from "xstate";
export type PreferenceRequest = { account: string; generation: number; value: string;};export type PreferenceReceipt = PreferenceRequest & { outcome: "confirmed" | "rejected";};export type PreferencePorts = { save(request: PreferenceRequest): Promise<PreferenceReceipt>;};type Input = { ports: PreferencePorts; uncertaintyMs: number };type Context = Input & { account: string | null; generation: number; value: string; requested: string;};
export const preferenceMachine = setup({ types: {} as { context: Context; input: Input; events: | { type: "SIGN_IN"; account: string; initial: string } | { type: "SIGN_OUT" } | { type: "SAVE"; value: string; generation: number }; }, actors: { save: fromPromise( ({ input, }: { input: { ports: PreferencePorts; request: PreferenceRequest }; }) => input.ports.save(input.request), ), }, delays: { uncertainty: ({ context }) => context.uncertaintyMs }, guards: { currentIntent: ({ context, event }) => event.type === "SAVE" && event.generation === context.generation, }, actions: { signIn: assign(({ context, event }) => event.type === "SIGN_IN" ? { account: event.account, generation: context.generation + 1, value: event.initial, requested: "", } : {}, ), signOut: assign(({ context }) => ({ account: null, generation: context.generation + 1, value: "", requested: "", })), request: assign(({ event }) => event.type === "SAVE" ? { requested: event.value } : {}, ), },}).createMachine({ id: "preference", context: ({ input }) => ({ ...input, account: null, generation: 0, value: "", requested: "", }), initial: "signedOut", states: { signedOut: { on: { SIGN_IN: { target: "active", actions: "signIn" } } }, active: { initial: "idle", on: { SIGN_IN: { target: "active", reenter: true, actions: "signIn" }, SIGN_OUT: { target: "signedOut", actions: "signOut" }, }, states: { idle: { on: { SAVE: { guard: "currentIntent", target: "saving", actions: "request", }, }, }, saving: { initial: "pending", invoke: { src: "save", input: ({ context }) => { if (context.account === null) throw Error("No account owns this request"); return { ports: context.ports, request: { account: context.account, generation: context.generation, value: context.requested, }, }; }, onDone: [ { guard: ({ context, event }) => event.output.account === context.account && event.output.generation === context.generation && event.output.outcome === "confirmed", target: "confirmed", actions: assign({ value: ({ event }) => event.output.value }), }, { guard: ({ context, event }) => event.output.account === context.account && event.output.generation === context.generation && event.output.outcome === "rejected", target: "rejected", }, { target: ".unknown" }, ], onError: ".unknown", }, // The receipt invocation outlives pending, but not its signed-in source state. states: { pending: { after: { uncertainty: "unknown" } }, unknown: {}, }, }, confirmed: { on: { SAVE: { guard: "currentIntent", target: "saving", actions: "request", }, }, }, rejected: { on: { SAVE: { guard: "currentIntent", target: "saving", actions: "request", }, }, }, }, }, },});Handle late confirmations
Section titled “Handle late confirmations”The receipt actor lives above pending and unknown.
A native XState deadline does not discard a later confirmation while that operation is live.
Account and generation checks reject mismatched receipts.
A transport error is unknown, not a confirmed rejection or permission to retry.
Ending local observation or exiting a source state does not undo backend work already accepted.
/** @jsxImportSource ignite-element/jsx */import { igniteCore } from "ignite-element/xstate";import { createActor } from "xstate";import { type PreferencePorts, preferenceMachine } from "./shared-session";
const ports: PreferencePorts = { save: async (request) => ({ ...request, outcome: "confirmed" }),};// The application constructs and owns this actor, exactly as in the counter.export const source = createActor(preferenceMachine, { input: { ports, uncertaintyMs: 5000 },}).start();export const core = igniteCore({ source, states: (snapshot) => ({ value: snapshot.context.value, generation: snapshot.context.generation, signedIn: snapshot.matches("active"), unknown: snapshot.matches({ active: { saving: "unknown" } }), pending: snapshot.matches({ active: { saving: "pending" } }), canSave: snapshot.can({ type: "SAVE", value: "compact", generation: snapshot.context.generation, }), }), commands: ({ source: actor }) => ({ signIn: (account: string, initial: string) => actor.send({ type: "SIGN_IN", account, initial }), signOut: () => actor.send({ type: "SIGN_OUT" }), save: (value: string, generation: number) => actor.send({ type: "SAVE", value, generation }), }),});export const editor = core("session-preference-editor", (ctx) => ( <section> <output>{ctx.signedIn ? ctx.value : "Signed out"}</output> <button type="button" disabled={!ctx.canSave} onClick={() => ctx.save("compact", ctx.generation)} > Use compact </button> </section>));export const summary = core("session-preference-summary", (ctx) => ( <p> {ctx.pending ? "Pending" : ctx.unknown ? "Outcome unknown" : ctx.signedIn ? ctx.value : "Signed out"} </p>));source.send({ type: "SIGN_IN", account: "synthetic-A", initial: "comfortable",});// core.dispose() never stops this borrowed source.// At final shutdown the application separately calls source.stop().The view sends one intent from ctx; it does not orchestrate completion or repeat projection logic.
The generation identifies the account session associated with each request.
Pass application services through the source’s native actor input.
Tests inject SimulatedClock directly.
Retain source-side business eligibility, authentication, receipt validation and remote cancellation in a real application.
These synthetic rules are not Ignite authorization.