Skip to content

Views

The renderer turns source-derived values into a view.

Use ctx to access those values and source-directed commands without duplicating state in the view.

Call core('my-component', ctx => ...), as in the light switch.

JSX files use .tsx and jsxImportSource: "ignite-element/jsx".

A source-free layout can use igniteCore() from ignite-element; it returns only a registrar, without the source-backed runtime methods.

Install react and react-dom for a web application.

Use React’s JSX runtime in React files and the Ignite pragma in files that register Ignite JSX components.

Increment either counter or edit its label.

Both views update because they use the same core and source.

Create the shared core outside rendering, then call const ctx = useIgnite(core).

Save these modules together:

counter-core.ts
import { igniteCore } from "ignite-element/xstate";
import { assign, createActor, createMachine } from "xstate";
const counterMachine = createMachine({
types: {} as {
events:
| { type: "INCREMENT" }
| { type: "DECREMENT" }
| { type: "LABEL"; value: string };
},
context: { count: 0, label: "Visitors" },
on: {
INCREMENT: {
actions: assign({ count: ({ context }) => context.count + 1 }),
},
DECREMENT: {
actions: assign({ count: ({ context }) => context.count - 1 }),
},
LABEL: {
actions: assign({ label: ({ event }) => event.value }),
},
},
});
// The application starts this shared actor and owns its shutdown.
export const source = createActor(counterMachine).start();
export const core = igniteCore({
source,
states: (snapshot) => ({
count: snapshot.context.count,
label: snapshot.context.label,
canIncrement: snapshot.can({ type: "INCREMENT" }),
}),
commands: ({ source: actor }) => ({
increment: () => actor.send({ type: "INCREMENT" }),
decrement: () => actor.send({ type: "DECREMENT" }),
setLabel: (value: string) => actor.send({ type: "LABEL", value }),
}),
});
shared-counter.tsx
/** @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>
);
}

Render <SharedCounters /> in your React application.

counters.css — styles for both demos
counters.css
.ignite-counter-demo {
color: var(--sl-color-text, CanvasText);
font-family: system-ui, sans-serif;
}
.ignite-counter-demo .counter-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr));
gap: 1rem;
align-items: stretch;
}
.ignite-counter-demo .counter-card {
display: grid;
gap: 1rem;
padding: 1.25rem;
border: 1px solid var(--sl-color-gray-4, GrayText);
border-radius: 0.5rem;
background: var(--sl-color-bg, Canvas);
}
.ignite-counter-demo p {
margin: 0;
}
.ignite-counter-demo output {
font-size: 2rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.ignite-counter-demo .counter-controls {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.ignite-counter-demo label {
display: grid;
gap: 0.375rem;
}
.ignite-counter-demo button,
.ignite-counter-demo input {
box-sizing: border-box;
min-width: 0;
min-height: 2.75rem;
padding: 0.375rem 0.625rem;
border: 1px solid var(--sl-color-gray-4, GrayText);
border-radius: 0.375rem;
background: var(--sl-color-bg, Canvas);
color: inherit;
font: inherit;
}
.ignite-counter-demo button {
cursor: pointer;
}
.ignite-counter-demo button:hover {
background: var(--sl-color-bg-inline-code, ButtonFace);
}
.ignite-counter-demo :is(button, input):focus-visible {
outline: 2px solid var(--sl-color-text-accent, Highlight);
outline-offset: 3px;
}
.ignite-counter-demo .event-status {
font-size: 1rem;
font-weight: 400;
display: grid;
place-content: center;
min-height: 8rem;
padding: 1.25rem;
border: 1px solid var(--sl-color-gray-4, GrayText);
border-radius: 0.5rem;
text-align: center;
font-variant-numeric: tabular-nums;
background: var(--sl-color-bg, Canvas);
}
.ignite-counter-demo [data-parity="even"] {
color: var(--sl-color-blue-high, #123b70);
background: var(--sl-color-blue-low, #e4efff);
}
.ignite-counter-demo [data-parity="odd"] {
color: var(--sl-color-purple-high, #542175);
background: var(--sl-color-purple-low, #f2e8ff);
}

This is the runnable React example.

Both views observe the same count and label.

useIgnite(core) automatically unsubscribes when the component unmounts.

No cleanup useEffect is needed in the view.

The core stays available for other views and later mounts.

Call core.dispose() when you permanently discard that core, such as when its owning feature or session ends.

If you supplied an external actor, its owner stops it when nothing else needs it.

See the session cleanup example.

This example passes an existing actor, so the core is ready for useIgnite(core) without a preparation read.

Passing a machine, slice or fresh factory instead gives each hook private state with automatic runtime cleanup on unmount and fresh state on a genuine new mount.

The same synchronous ctx works in React Native.

Construction safety and the separate explicit headless runtime are described in ownership.

Use the same neutral ignite-element/react hook and native controls.

This is a complete view module using the shared counter-core.ts above.

CounterScreen.tsx
import { useIgnite } from "ignite-element/react";
import { Pressable, Text, View } from "react-native";
import { core } from "./counter-core";
export function CounterScreen() {
const ctx = useIgnite(core);
return (
<View>
<Text>{ctx.count}</Text>
<Pressable onPress={() => ctx.decrement()}>
<Text>Decrement</Text>
</Pressable>
<Pressable onPress={() => ctx.increment()}>
<Text>Increment</Text>
</Pressable>
</View>
);
}

The native consumer fixture checks real React Native types and its standard Jest host.

That is not device or simulator acceptance.

SSR/hydration and Solid/Vue headless bindings are not implemented.

When React intentionally hosts a real element, use igniteReact(handle) from ignite-element/react/web.

Use the custom element’s buttons to change its count.

React receives countChanged through onCountChanged and updates the status beside it.

The odd/even label and color belong to the React view.

onCountChanged receives the event’s data directly: { count }.

src/WebInterop.tsx
/** @jsxImportSource react */
import { useState } from "react";
import { Counter } from "./counter.react";
import "../counters.css";
export function WebInterop() {
const [count, setCount] = useState<number | null>(null);
const parity = count === null ? undefined : count % 2 === 0 ? "even" : "odd";
return (
<section
className="ignite-counter-demo"
aria-label="Custom-element interoperability"
>
<div className="counter-grid">
<Counter onCountChanged={({ count }) => setCount(count)} />
<output
className="event-status"
aria-label="React event status"
data-parity={parity}
>
{count === null
? "Waiting for an event."
: `React received: ${count} — ${parity === "even" ? "Even" : "Odd"}`}
</output>
</div>
</section>
);
}

Render <WebInterop /> in your React application.

igniteReact creates the React component from the registered element handle.

src/counter.react.ts
import { igniteReact } from "ignite-element/react/web";
import { counterElement } from "./counter.ignite";
export const Counter = igniteReact(counterElement);

The element declares countChanged and emits it when the selected count changes.

src/counter.ignite.tsx
/** @jsxImportSource ignite-element/jsx */
import { igniteCore } from "ignite-element/xstate";
import { assign, createMachine } from "xstate";
const counterMachine = createMachine({
context: { count: 0 },
on: {
INCREMENT: {
actions: assign({ count: ({ context }) => context.count + 1 }),
},
DECREMENT: {
actions: assign({ count: ({ context }) => context.count - 1 }),
},
},
});
const counterCore = igniteCore({
source: counterMachine,
states: (snapshot) => ({ count: snapshot.context.count }),
commands: ({ source }) => ({
increment: () => source.send({ type: "INCREMENT" }),
decrement: () => source.send({ type: "DECREMENT" }),
}),
events: (event) => ({
countChanged: event<{ count: number }>(),
}),
effects: ({ emit, select }) => {
const count = select((snapshot) => snapshot.context.count);
if (count.changed) emit({ type: "countChanged", count: count.current });
},
});
export const counterElement = counterCore("react-demo-counter", (ctx) => (
<section class="counter-card" aria-label="Ignite counter">
<link
rel="stylesheet"
href={new URL("./counter.css", import.meta.url).href}
/>
<p>Custom element</p>
<output aria-label="Element count">{ctx.count}</output>
<div class="counter-controls">
<button
type="button"
aria-label="Decrement"
onClick={() => ctx.decrement()}
>
−
</button>
<button
type="button"
aria-label="Increment"
onClick={() => ctx.increment()}
>
+
</button>
</div>
</section>
));
src/counter.css — styles inside the element
src/counter.css
:host {
display: block;
color: inherit;
font: inherit;
}
.counter-card {
display: grid;
justify-items: center;
gap: 0.75rem;
padding: 1.25rem;
border: 1px solid var(--sl-color-gray-4, GrayText);
border-radius: 0.5rem;
background: var(--sl-color-bg, Canvas);
}
p {
margin: 0;
}
output {
font-size: 2rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.counter-controls {
display: flex;
gap: 0.5rem;
}
button {
min-width: 2.75rem;
min-height: 2.75rem;
border: 1px solid var(--sl-color-gray-4, GrayText);
border-radius: 0.375rem;
background: var(--sl-color-bg, Canvas);
color: inherit;
font: inherit;
cursor: pointer;
}
button:hover {
background: var(--sl-color-bg-inline-code, ButtonFace);
}
button:focus-visible {
outline: 2px solid var(--sl-color-text-accent, Highlight);
outline-offset: 3px;
}

The live demos run these same modules from the React example.

Plain HTML, Vue, and Svelte can use the registered tag, attributes, native DOM events, and element commands.

These browser capabilities do not imply a new Ignite Vue/Solid hook.

See the framework examples.

Use native CSS inside the shadow root.

Return a <style> element containing this CSS alongside the view:

Component CSS (fragment)
:host {
display: block;
color: var(--counter-color, currentColor);
}
button {
font: inherit;
padding: 0.5rem 1rem;
}

Global selectors do not pierce shadow roots; inherited custom properties support theming.

Account for your application’s CSP when using inline styles.

Use native controls, labels, and keyboard behavior; see accessibility.

Retained Canvas, WebGL, editor, map, and native view resources belong to presentation lifecycle facilities.

Ignite effects are notifications, not resource or commit hooks.