Preact own declarative DOM. They work well together when the boundary is explicit: IWSDK remains authoritative for the experience, while the UI renders small, immutable projections of world state and sends commands back to the world.Preact, followed by the equivalent React subscription adapter.Preact, install it:npm install preact
Preact or React, rename the application entry file:mv src/index.ts src/index.tsx
index.html to match:<script type="module" src="/src/index.tsx"></script>
include list to cover .tsx files:{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "preact"
},
"include": ["src/**/*.ts", "src/**/*.tsx"]
}
npm install react react-dom npm install --save-dev @types/react @types/react-dom
"jsx": "react-jsx" without jsxImportSource for React. In either case, keep the IWSDK development plugin and virtual:iwsdk-project import in the application.| Owner | What belongs there | Examples |
|---|---|---|
IWSDK ECS | Durable experience and simulation state | Components, entity membership, level state, interactions |
Three.js objects | High-frequency render state managed by systems | Matrices, animation pose, camera motion |
Shared signals | Immutable UI projections of ECS state | XR visibility, selected item summary, counts, progress |
Component-local UI state | Ephemeral interaction state | Open menu, draft input, focused tab |
Object3D instances that IWSDK systems also mutate. Do not let both an ECS component and a UI signal independently claim to be the source of truth for the same value.IWSDK events and systems -> immutable projection signal -> React/`Preact` render React/`Preact` event -> typed bridge command -> IWSDK mutation
@preact/signals-core for world state and system configuration. Reuse that reactive contract instead of adding a second application store.import {
RayInteractable,
VisibilityState,
createSystem,
signal,
} from '@iwsdk/core';
type ChoiceSummary = {
key: string;
label: string;
};
type HudSnapshot = {
choices: ChoiceSummary[];
visibility: VisibilityState;
};
export class HudBridgeSystem extends createSystem({
choices: { required: [RayInteractable] },
}) {
readonly snapshot = signal<HudSnapshot>({
choices: [],
visibility: this.visibilityState.value,
});
init() {
const publishChoices = () => {
this.snapshot.value = {
...this.snapshot.peek(),
choices: [...this.queries.choices.entities].map((entity) => ({
// Good for one world lifetime. Use an authored ID component when the
// identity must survive reloads or serialized scene revisions.
key: `${entity.index}:${entity.generation}`,
label: entity.object3D?.name || `Entity ${entity.index}`,
})),
};
};
this.cleanupFuncs.push(
this.visibilityState.subscribe((visibility) => {
this.snapshot.value = { ...this.snapshot.peek(), visibility };
}),
this.queries.choices.subscribe('qualify', publishChoices),
this.queries.choices.subscribe('disqualify', publishChoices),
);
publishChoices();
}
activate(key: string) {
const entity = [...this.queries.choices.entities].find(
(candidate) => `${candidate.index}:${candidate.generation}` === key,
);
if (!entity?.active) return;
// Perform the domain action here. Validate current entity/component state,
// then use addComponent, removeComponent, setValue, or another IWSDK API.
}
}
qualify and disqualify subscriptions describe membership changes. Publish an initial snapshot as well.cleanupFuncs; World.destroy() and system teardown will run them.activate or select, not general entity mutation from UI components.Preact@preact/signals-core is the shared contract. A small hook connects it to Preact without requiring the Preact-specific signals package:import type { ReadonlySignal } from '@iwsdk/core';
import { useEffect, useState } from 'preact/hooks';
export function useIwsdkSignal<T>(source: ReadonlySignal<T>): T {
const [value, setValue] = useState(() => source.peek());
useEffect(() => source.subscribe(setValue), [source]);
return value;
}
function Hud({ bridge }: { bridge: HudBridgeSystem }) {
const snapshot = useIwsdkSignal(bridge.snapshot);
return (
<nav aria-label="Scene choices">
{snapshot.choices.map((choice) => (
<button key={choice.key} onClick={() => bridge.activate(choice.key)}>
{choice.label}
</button>
))}
</nav>
);
}
signals-core. Adding an adapter package that resolves a second core version can produce two signal runtimes or confuse Vite dependency optimization. Keep one signals-core version in the application graph.@preact/signals, reading .value directly in a Preact component is valid. Confirm that its signals-core range resolves to the same version IWSDK uses.React, useSyncExternalStore provides the equivalent boundary. The snapshot must remain referentially stable until the signal changes, which is why the bridge publishes immutable objects.import type { ReadonlySignal } from '@iwsdk/core';
import { useCallback, useSyncExternalStore } from 'react';
export function useIwsdkSignal<T>(source: ReadonlySignal<T>): T {
const subscribe = useCallback(
(notify: () => void) => source.subscribe(notify),
[source],
);
const getSnapshot = useCallback(() => source.peek(), [source]);
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}
Preact. Only this framework adapter changes.index.html with one DOM-framework mount point:<div id="app"></div>
Preact component creates the element that IWSDK owns and passes the project manifest options to the world:import { World } from '@iwsdk/core';
import { useEffect, useRef, useState } from 'preact/hooks';
import projectOptions from 'virtual:iwsdk-project';
function Experience() {
const viewportRef = useRef<HTMLDivElement>(null);
const [bridge, setBridge] = useState<HudBridgeSystem | null>(null);
useEffect(() => {
let cancelled = false;
let world: World | undefined;
void World.create(viewportRef.current!, projectOptions).then((created) => {
world = created;
if (cancelled) {
world.destroy();
return;
}
world.registerSystem(HudBridgeSystem);
setBridge(world.getSystem(HudBridgeSystem) ?? null);
});
return () => {
cancelled = true;
world?.destroy();
};
}, []);
return (
<main class="experience-shell">
<div ref={viewportRef} class="experience-viewport" />
<aside class="experience-hud">
{bridge ? <Hud bridge={bridge} /> : null}
</aside>
</main>
);
}
src/index.tsx:import { render } from 'preact';
const app = document.getElementById('app');
if (!(app instanceof HTMLDivElement)) {
throw new Error('Missing #app');
}
render(<Experience />, app);
createRoot(app).render(<Experience />) from react-dom/client and change JSX class attributes to className.World.destroy() is idempotent and tears down systems, render-loop listeners, and registered cleanup functions. This matters during tests, hot reload, route changes, and React development modes that intentionally exercise mount cleanup..experience-shell {
position: relative;
min-height: 100dvh;
}
.experience-viewport {
position: absolute;
inset: 0;
}
.experience-hud {
position: absolute;
inset: 0;
pointer-events: none;
}
.experience-hud button,
.experience-hud input,
.experience-hud select {
pointer-events: auto;
}
System.update() only to mirror transforms.Object3D state. If a 2D readout truly needs live telemetry, sample it at a fixed low rate, publish only when the displayed value changes, and stop sampling when the panel is hidden.import { useState } from 'preact/hooks';
type VolumeControlProps = {
initial: number;
onCommit: (volume: number) => void;
};
function VolumeControl({ initial, onCommit }: VolumeControlProps) {
const [draft, setDraft] = useState(initial);
return (
<form
onSubmit={(event) => {
event.preventDefault();
onCommit(draft);
}}
>
<input
type="range"
min="0"
max="1"
step="0.01"
value={draft}
onInput={(event) => setDraft(Number(event.currentTarget.value))}
/>
<button type="submit">Apply</button>
</form>
);
}
onCommit. For continuous controls, either update on input with explicit throttling or commit on change or pointer release. Choose based on the experience, not framework convenience.idle, loading, ready, and error explicitly for asynchronous projections.AbortController; ignore stale completions.XRFrame, transient hit-test results, or other frame-scoped objects in signals.Preact integration, verify:@preact/signals-core runtime is installed.