Skip to content

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.

shared-counter.tsx
/** @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.

Create the store once and pass the instance to igniteCore.

Save this complete example as redux.ts:

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.

Create the observable once and pass the instance to igniteCore.

Save this complete example as mobx.ts:

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.

Source inputOwnership
Existing XState actorApplication-owned; Ignite releases only its observation resources
XState machine definitionExisting Ignite acquisition creates private isolated actors and owns their release
Existing Redux store or MobX observableApplication-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 shared counter
/** @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.

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.

shared-session.ts
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",
},
},
},
},
},
},
});

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.

shared-session-view.tsx
/** @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.