Skip to content

UI (React)

The @yagejs/ui-react package provides a React reconciler over the UI system, letting you write game UI with JSX. It includes hooks for accessing engine services and reactive stores for bridging ECS state into React.

Install both @yagejs/ui and @yagejs/ui-react, plus React:

Terminal window
npm install @yagejs/ui @yagejs/ui-react react

Both UIPlugin and UIReactPlugin must be registered before using React UI. UIReactPlugin registers the LateUpdate-phase layout pass that positions UIRoot trees after any Update-phase Transform writers (e.g. ScreenFollow):

import { UIPlugin } from "@yagejs/ui";
import { UIReactPlugin } from "@yagejs/ui-react";
engine.use(new UIPlugin());
engine.use(new UIReactPlugin());

UIRoot is a component that hosts a React tree on an entity:

import { Transform } from "@yagejs/core";
import { UIRoot, Text } from "@yagejs/ui-react";
import { Anchor } from "@yagejs/ui";
function MyMenu() {
return <Text>Main menu</Text>;
}
const ui = scene.spawn("ui");
ui.add(new Transform());
const root = new UIRoot({ anchor: Anchor.Center });
ui.add(root);
root.render(<MyMenu />);

By default the React tree renders into the auto-provisioned screen-space "ui" layer, positioned by anchor and optional offset against the viewport. Pass layer: "<name>" to mount on any layer declared on Scene.layers.

setOffset(x, y) moves a mounted tree without changing its anchor, and offset reads the current value back:

root.setOffset(0, -120); // slide the tree 120 px up
root.offset; // { x: 0, y: -120 }

Both values must be finite. A NaN or an infinity throws and names the argument.

Set consumeInput: false on the root of a HUD that should leave the world clickable. Every child inherits unless it supplies its own value. Set consumeInput on a button that should handle its click without also triggering a gameplay action:

import { UIRoot, ZStack, Panel, Text, Button } from "@yagejs/ui-react";
declare function pause(): void;
const root = entity.add(new UIRoot({ consumeInput: false }));
root.render(
<ZStack width={800} height={600}>
<Panel><Text>Score: 42</Text></Panel>
<Button consumeInput onClick={pause}>Pause</Button>
</ZStack>,
);

A press on the score or empty layout space reaches gameplay. The pause button and its label consume their presses. Wheel input follows the same rule.

The nearest explicit true or false wins, so a false child can also pass input through a consuming parent. Removing the prop restores inheritance. UIRoot defaults to true, as does a standalone UI element with no explicit ancestor setting. The rule also applies to the imperative UISurface API.

Both settings preserve the element’s pointer callbacks. They control gameplay input, not whether another UI widget beneath the element receives a click. Inheritance follows the display hierarchy: content moved into a floating overlay needs its own setting. PixiSelect applies its explicit setting to the open list as well. Without one, the detached list uses the standalone UI default. Tooltip bubbles set false for their content. A modal focus scope’s blocker still consumes input outside the scope; use focus={{ modal: false }} when that surrounding area must remain clickable.

For UI that tracks a specific game entity (nameplates, health bars, damage numbers), set positioning: "transform" on the UIRoot and pair with ScreenFollow from @yagejs/renderer. ScreenFollow writes camera.worldToScreen(target) + offset to this entity’s Transform each frame (the offset is in screen pixels, applied after projection), and the UIRoot reads that Transform instead of the viewport — so the UI tracks the target while staying axis-aligned and constant-size under any camera zoom or rotation.

import { Entity, Transform, Vec2 } from "@yagejs/core";
import { ScreenFollow } from "@yagejs/renderer";
import type { CameraEntity } from "@yagejs/renderer";
import { UIRoot, Panel, Text } from "@yagejs/ui-react";
import { Anchor } from "@yagejs/ui";
class EnemyNameplate extends Entity {
setup(params: {
target: Entity; camera: CameraEntity; label: string;
}) {
this.add(new Transform());
this.add(new ScreenFollow({
target: params.target,
camera: params.camera,
offset: new Vec2(0, -40), // 40 screen px above the target
}));
const root = this.add(new UIRoot({
positioning: "transform", // read Transform.worldPosition
anchor: Anchor.BottomCenter, // pivot on the rendered tree
}));
root.render(<NameplateView label={params.label} />);
}
}
function NameplateView({ label }: { label: string }) {
return (
<Panel padding={4} bg={{ color: 0x000000, alpha: 0.6, radius: 4 }}>
<Text style={{ fontSize: 11, fill: 0xffffff }}>{label}</Text>
</Panel>
);
}

UIRoot with positioning: "transform" requires a Transform on the entity and throws at add time otherwise. See the imperative UI guide for the full positioning model (the two modes, the world-space-layer variant for genuinely diegetic UI, and when to pick which).

React components mirror the imperative @yagejs/ui API:

import { useState } from "react";
import { Panel, Text, Button, Image, ProgressBar, Checkbox } from "@yagejs/ui-react";
function ClickDemo() {
const [clicks, setClicks] = useState(0);
return (
<Panel direction="column" gap={8} padding={16} bg={{ color: 0x000000, alpha: 0.7 }}>
<Text style={{ fontSize: 24, fill: 0xffffff }}>{`Clicks: ${clicks}`}</Text>
{/* width/height are optional — omit them and the button shrinks to
fit its content. */}
<Button
bg={{ color: 0x4444aa }}
hoverBg={{ color: 0x5555cc }}
textStyle={{ fontSize: 16, fill: 0xffffff }}
onClick={() => setClicks((c) => c + 1)}
>
Click me
</Button>
<ProgressBar
width={200}
height={16}
value={clicks / 10}
fillBackground={{ color: 0x44cc44 }}
trackBackground={{ color: 0x333333 }}
/>
<Checkbox
label="Mute"
checked={false}
onChange={(v) => console.log("mute:", v)}
/>
</Panel>
);
}

<Button> accepts any ReactNode as children. Pass a string for the common labeled-button case (it’s auto-wrapped in a centered <Text> styled with textStyle), or pass JSX children to compose icons and text. A button lays its children out in a column, so an icon beside a label needs direction="row":

import { texture } from "@yagejs/renderer";
import { Button, Image, Text } from "@yagejs/ui-react";
const saveIcon = texture("icons/save.png");
declare function save(): void;
<Button direction="row" onClick={() => save()}>
<Image texture={saveIcon} width={16} height={16} />
<Text style={{ fontSize: 14, fill: 0xffffff }}>Save</Text>
</Button>

<Text> is the counterpart: it takes a single string child. Pre-join interpolated values into one template string rather than mixing static text with expressions:

import { Text } from "@yagejs/ui-react";
const score = 42;
<Text>{`Score: ${score}`}</Text>; // one string child
// yage-expect-error TS2322
<Text>Score: {score}</Text>; // mixed children — fails typecheck
ComponentDescription
<Panel>Flexbox container with direction, gap, padding, bg
<ZStack>Z-axis overlay panel — fills its parent, children layer via position="absolute"
<Text>Text label with style
<Button>Clickable button with onClick, bg, hoverBg, pressBg
<Image>Texture display with texture, tint, alpha; set exactly one of width or height and the other follows the texture’s aspect ratio (flex sizing turns that off)
<NineSlice>Nine-slice scalable texture
<ProgressBar>Progress bar (value 0–1); a ref exposes the node, whose value reads the drawn fraction
<Checkbox>Checkbox with checked, onChange, label
<Tooltip>Hover-driven floating label wrapping a trigger

Setting a prop to undefined, or dropping it with a conditional spread, resets it to its default instead of leaving the previous value in place:

import { Panel } from "@yagejs/ui-react";
declare const selected: boolean;
const highlightBg = { color: 0x3355aa };
{/* Clears the background on the render where `selected` is false, rather
than keeping the last highlight color. */}
<Panel bg={selected ? highlightBg : undefined} />
{/* Same reset applies when the key disappears from a spread. */}
<Panel {...(selected ? { bg: highlightBg } : {})} />

This holds for every prop on every component — a removed onClick unbinds the handler, a removed width goes back to Yoga’s auto sizing, and so on. A default is not always empty: a <Button> with no bg paints its own grey fill.

The exception is the Pixi* wrappers’ creation options: their views (bg, fill, slider, the checkbox views), text styles, and <PixiSlider>’s showValue are read once, when the element is created. Changing one later, to undefined or to a new value, has no effect. To swap one, give the element a new key so it is created again.

bg is shorthand for background on <Panel>, <Button>, and <ScrollView> (<Button> also has hoverBg / pressBg for its hover and press backgrounds). Passing both bg and background on the same element uses background and logs a dev warning once per component type.

The Pixi* wrapper components (<PixiProgressBar>, <PixiSlider>, <PixiInput>) have their own bg prop — a required @pixi/ui view-slot value, unrelated to background — and are not affected by this shorthand.

Every component accepts the shared layout props position, left, top, right, bottom. Combine them with a relative parent to overlay elements:

import { Panel, ProgressBar, Text } from "@yagejs/ui-react";
declare const progress: number; // 0–1
<Panel position="relative" width={400} height={300}>
<ProgressBar width={400} height={300} value={progress} />
<Panel position="absolute" top={8} right={8} padding={4} bg={{ color: 0x000000, alpha: 0.6 }}>
<Text style={{ fontSize: 12 }}>{`${Math.round(progress * 100)}%`}</Text>
</Panel>
</Panel>

<ZStack> is a <Panel> that defaults to width: "100%", height: "100%", and position: "relative". Drop absolute-positioned children inside to layer them at (0, 0) on the Z axis — modal backdrops, HUD layers, and badge markers. The name follows the SwiftUI convention (VStack / HStack for flow layout, ZStack for depth); for column / row stacking in YAGE use <Panel direction="column" | "row">.

import { Button, Panel, Text, ZStack } from "@yagejs/ui-react";
declare function onConfirm(): void;
declare function onCancel(): void;
<ZStack>
{/* Dimmed backdrop fills the stack */}
<Panel position="absolute" left={0} top={0} width="100%" height="100%"
bg={{ color: 0x000000, alpha: 0.6 }} />
{/* Centered modal */}
<Panel position="absolute" left={0} top={0} width="100%" height="100%"
alignItems="center" justifyContent="center">
<Panel padding={24} gap={12} bg={{ color: 0x222222, radius: 8 }}>
<Text style={{ fontSize: 20 }}>Are you sure?</Text>
<Panel direction="row" gap={8}>
<Button onClick={onConfirm}>Confirm</Button>
<Button onClick={onCancel}>Cancel</Button>
</Panel>
</Panel>
</Panel>
</ZStack>

Every component takes transformOrigin, scale, rotation and zIndex. They change how it is drawn, never where layout puts it, and transformOrigin={0.5} turns and scales about the centre. The imperative UI guide has the details.

A card that pops while the pointer is on it:

import { useState } from "react";
import { Panel, Text } from "@yagejs/ui-react";
function Card({ title }: { title: string }) {
const [hovered, setHovered] = useState(false);
return (
<Panel
width={120}
height={160}
transformOrigin={0.5}
scale={hovered ? 1.08 : 1}
zIndex={hovered ? 1 : 0}
onHover={setHovered}
bg={{ color: 0x202030, radius: 8 }}
>
<Text>{title}</Text>
</Panel>
);
}

Drive a transform through the prop, as above. A component passes every prop it has again on each render, so a scale prop replaces any value written to the element some other way.

<Panel>, <Button>, <Text>, <SplitText>, <Image>, <NineSlice>, <ProgressBar>, <Checkbox>, <ScrollView> and the interactive Pixi* wrappers accept hover callbacks. Their containers are already interactive, so there is no separate “make it hoverable” step. On a <ScrollView> the callbacks fire anywhere over the viewport box, the gutter and the gaps between rows included.

Three independent props (combine them freely):

  • onPointerOver / onPointerOut — fire on pointer enter / leave. They mirror Pixi’s event names and the existing onClick, so reach for these when enter and leave need separate handlers.
  • onHover(hovering) — the convenience form: called with true on enter and false on leave. One setter drives a “while hovered” state.
import { useState } from "react";
import { Panel, Text } from "@yagejs/ui-react";
function HoverableCard() {
const [active, setActive] = useState(false);
return (
<Panel
onHover={setActive}
bg={{ color: active ? 0x3355aa : 0x223044 }}
padding={12}
>
<Text>{active ? "Release to deselect" : "Hover me"}</Text>
</Panel>
);
}

Hover callbacks are suppressed on a component whose disabled state is on: a <Button disabled>, a <Checkbox disabled>, and a Pixi* wrapper whose widget carries an enabled flag. A disabled component takes no pointer events at all, which is the same reason its hover background stays unpainted.

focus on a <Panel> makes it a focus scope over everything inside it: the direction actions move a highlight between the rows, confirm presses the highlighted one and runs it on the release, cancel calls your onCancel. The imperative UI guide covers the model in full — the action names, wrapping, scrolling lists, nested dialogs, and keeping menu input out of gameplay. The props are the same ones in JSX.

import { useState } from "react";
import { Button, Checkbox, Panel, ScrollView, Text } from "@yagejs/ui-react";
interface SaveSlot { id: string; label: string; empty: boolean }
function PauseMenu({
slots,
onResume,
onLoad,
}: {
slots: SaveSlot[];
onResume: () => void;
onLoad: (id: string) => void;
}) {
const [volume, setVolume] = useState(60);
const [music, setMusic] = useState(true);
return (
<Panel
gap={8}
padding={16}
bg={{ color: 0x101018, alpha: 0.9, radius: 8 }}
focus={{ wrap: true, onCancel: onResume }}
>
<Button width={220} onClick={onResume}>Resume</Button>
{/* Skipped by navigation, and confirm refuses it as a click would. */}
<Button width={220} disabled onClick={() => undefined}>
Upload to cloud
</Button>
{/* Left and right step the value and keep the focus; up and down move. */}
<Panel
direction="row"
gap={12}
width={220}
bg={{ color: 0x222230, radius: 4 }}
focusable
focusId="volume"
focusNeighbors={{ down: "saves-first" }}
onAdjust={(d) => setVolume((v) => Math.max(0, Math.min(100, v + d * 5)))}
>
<Text>{`Volume ${volume}`}</Text>
</Panel>
<Checkbox label="Music" checked={music} onChange={setMusic} />
{/* Focus entering a row below the fold scrolls the list to it. */}
<ScrollView width={220} height={140} gap={4}>
{slots.map((slot, index) => (
<Button
key={slot.id}
width="100%"
disabled={slot.empty}
{...(index === 0 ? { focusId: "saves-first" } : {})}
onClick={() => onLoad(slot.id)}
>
{slot.label}
</Button>
))}
</ScrollView>
</Panel>
);
}

Put the menu’s callbacks in focus and let the object be rebuilt. A fresh focus={{ … }} literal on every render refreshes the scope’s options in place, so onCancel, onFocusMove, onActivate and onMoveBlocked are replaced per render and the highlight stays on the row the player left it on. That holds for a focus object built from state, too. The object is the whole declaration: a key a render leaves out goes back to its default, so a callback you stop passing stops running. Dropping the prop — an explicit undefined, or a key gone from a conditional spread — disposes the scope, and input goes to the next scope that is shown.

Focus draws nothing until you ask for it. Most games show the focused row from onFocusChange — a marker beside it, a swapped sprite, a sound:

import { useState } from "react";
import { Button, Panel, Text } from "@yagejs/ui-react";
declare function onResume(): void;
const [focused, setFocused] = useState(false);
<Panel direction="row" gap={6} alignItems="center">
<Text width={10}>{focused ? ">" : ""}</Text>
<Button flex={1} onFocusChange={setFocused} onClick={onResume}>
Continue
</Button>
</Panel>;

focusStyle asks for the package’s own outline, drawn just inside the component’s box. It sets the outline’s color, width, radius and inset on one component, over the focusStyle you gave UIPlugin for the whole UI; focusStyle={null} drops a UI-wide outline for that component alone. Hover and press keep their fills, so a row the pointer is on shows both. Holding confirm on the focused component paints it pressed and runs its action on the release.

focusBg fills the row that holds the focus. <Button> takes it beside hoverBg and pressBg, and a focusable <Panel> takes it on its own: a panel with no bg is transparent at rest and filled while the focus is on it, which is the menu row that appears only when the player reaches it. Omit it and a focused component keeps its resting background. A fill naming only a colour keeps the resting corner radius.

import { Button } from "@yagejs/ui-react";
declare function onLoad(): void;
<Button width={220} focusBg={{ color: 0x2c4a6f }} onClick={onLoad}>
Load game
</Button>
import { useState } from "react";
import { Panel, Text } from "@yagejs/ui-react";
declare const slot: { label: string; playtime: string };
const [rowFocused, setRowFocused] = useState(false);
<Panel
direction="row"
gap={6}
padding={4}
focusable
focusBg={{ color: 0x2c4a6f, radius: 4 }}
onFocusChange={setRowFocused}
>
<Text>{slot.label}</Text>
<Text>{slot.playtime}</Text>
</Panel>

What the mouse does. Pressing a component focuses it; passing the pointer over one leaves the focus where the keyboard put it, and the component still shows its hover tint. focus={{ pointerFocus: "hover" }} gives you the console-style lit row that follows the mouse, and "none" keeps the pointer out of the focus entirely.

The panel reading input owns the pointer. While a <Panel focus> holds the keys, everything drawn under it stops answering the mouse, so a confirm dialog cannot be clicked through. focus={{ modal: false }} turns that off, for a panel that wants the keys while the world behind it stays clickable. The block runs downwards from where the panel is drawn, so anything drawn above it stays interactive — a <Tooltip> bubble, a PixiSelect list, a surface added after the menu’s. A <ScrollView>, or any other container that clips what it draws, clips the block too: a focus panel inside one covers that view’s own rows and leaves every point outside the view clickable.

The blocker does not change consumeInput inside the panel. In a transparent HUD, use <Panel focus consumeInput> to keep presses on the dialog’s controls out of gameplay too. Children can still override that value.

For a tree whose outermost element is not a single <Panel>, put the option on the root instead:

import { UIRoot } from "@yagejs/ui-react";
import { Anchor } from "@yagejs/ui";
declare const slots: SaveSlot[];
declare function resume(): void;
declare function load(id: string): void;
const root = entity.add(new UIRoot({ anchor: Anchor.Center, focus: true }));
root.render(<PauseMenu slots={slots} onResume={resume} onLoad={load} />);
root.focusScope?.move("down"); // drive it by hand from anywhere

<Tooltip> wraps a trigger element and shows a floating bubble while the pointer is over it. It follows the Mantine convention — a single wrapper with the body in a content prop — rather than compound trigger/content subcomponents. It is headless: no default visuals — pass bg / padding / textStyle to style it.

import { Button, Tooltip } from "@yagejs/ui-react";
declare function save(): void;
<Tooltip
content="Save your game"
placement="top"
bg={{ color: 0x1f2430, radius: 6 }}
padding={8}
>
<Button onClick={save}>Save</Button>
</Tooltip>

content also takes arbitrary nodes for rich tooltips (item cards, stat breakdowns):

import { texture } from "@yagejs/renderer";
import { Image, Panel, Text, Tooltip } from "@yagejs/ui-react";
const swordIcon = texture("items/iron-sword.png");
<Tooltip
placement="right"
content={
<Panel gap={2}>
<Text style={{ fontWeight: "bold" }}>Iron Sword</Text>
<Text style={{ fontSize: 12 }}>+5 ATK · Rare</Text>
</Panel>
}
>
<Image texture={swordIcon} width={32} height={32} />
</Tooltip>
PropDescription
contentBubble body — string/number auto-wraps in <Text>; nodes for rich content
placementside or side-align: "top" (default, center-aligned), "bottom-start", "right-end", …
offsetGap in px between trigger and bubble (default 6)
maxWidthCap bubble width (px) — long content wraps instead of running off-screen
bg / padding / textStyleBubble styling (headless — omit for an unstyled bubble)
openedForce visibility, bypassing hover (controlled / debug)
disabledRender the trigger only — never show the bubble

Under a <UIRoot> (the normal case) the bubble is portaled into the scene’s top-most screen-space overlay and anchored by the positioning engine. It draws above all other UI, escapes a <ScrollView>’s clip, never reflows siblings, flips to the opposite side and shifts along the cross axis to stay on-screen, caps to maxWidth (and to the space available at the resolved side), z-stacks across roots (most-recently-opened on top), and anchors correctly even for world-space / camera-transformed triggers. Rendered without a <UIRoot> overlay (a bare reconciler tree) it falls back to an in-tree absolute bubble with no collision handling.

<Tooltip> is a thin wrapper over the exported useFloating primitive — use it directly for custom popovers, dropdowns, hovercards, or context menus. useFloating({ open, placement, offset, padding, maxWidth, flip, shift }) returns { setReference, renderFloating, hasOverlay }: wire setReference to the trigger’s ref and render renderFloating(node) in your tree — it portals node into the scene overlay while open (keeping it in your React tree so context/props/lifecycle still flow) and returns null when closed. The pure computePosition() engine and Placement type are also exported for fully custom floating layers.

The scene overlay is owned by UIPlugin (not UIReactPlugin), so the same floating system is available without React — see attachTooltip in the imperative UI guide for tooltips in the imperative layer.

Wrappers for advanced @pixi/ui widgets:

import {
PixiFancyButton,
PixiSlider,
PixiInput,
PixiSelect,
PixiRadioGroup,
} from "@yagejs/ui-react";

Any list whose length you can’t bound at design time — inventories, quest logs, chat, order tickets, leaderboards — should live inside a <ScrollView>. A plain <Panel> simply clips overflow without scrolling, so once the list outgrows its container the extra entries disappear silently. <ScrollView> is a true Yoga container: its children are ordinary elements laid out by the normal flexbox pass (not handed off to a foreign widget), it adds wheel + drag scrolling, and it preserves scroll position across re-renders — fulfilling or refilling a store-driven list never jumps the scroll back to the top.

import { ScrollView, Panel, Button, Text } from "@yagejs/ui-react";
interface Order { id: string; label: string }
function OrdersPanel({
orders,
fulfill,
endDay,
}: {
orders: Order[];
fulfill: (id: string) => void;
endDay: () => void;
}) {
return (
<Panel direction="column" width={300} height={220} gap={10} padding={10}
bg={{ color: 0x000000, alpha: 0.7 }}>
<Text style={{ fontSize: 16, fill: 0x93c5fd }}>Orders</Text>
<ScrollView flexGrow={1} gap={6} bg={{ color: 0x0b1220 }}>
{orders.map((o) => (
<Panel key={o.id} direction="row" height={36} padding={{ left: 8, right: 6 }}
alignItems="center" justifyContent="space-between"
bg={{ color: 0x243042, radius: 4 }}>
<Text style={{ fontSize: 14, fill: 0xe5e7eb }}>{o.label}</Text>
<Button height={24} onClick={() => fulfill(o.id)}>Fulfill</Button>
</Panel>
))}
</ScrollView>
{/* A sibling of <ScrollView>, so it stays put while the list scrolls. */}
<Button height={36} onClick={endDay}>End Day</Button>
</Panel>
);
}

Size the viewport with the standard layout props — height for a fixed box, or flexGrow={1} to fill the space left by fixed siblings. Content overflowing the scroll axis is clipped and pannable by wheel or drag — anywhere over the box, including the gaps between cards and the scrollbar gutter (not just directly over a card). Dragging starts after 10 px and does not activate a child button when released. Props: direction ("vertical" default, or "horizontal"), gap, padding, bg, onScroll(offset), and scrollbar — true (default) / false, or a ScrollbarOptions object (thickness, color, alpha, radius, minThumbLength, margin). When the thumb is shown a gutter equal to its footprint is auto-reserved so content never renders under it. Keep anything that should stay fixed — a header, a footer button — as a sibling of <ScrollView>, never a child.

For imperative control, a ref to the node exposes scrollBy(), scrollTo(), scrollOffset, and maxScroll. The same element is available outside React via the panel.scrollView(opts) builder on UISurface / UIPanel.

useEngine() returns the EngineContext, and useScene() returns the scene the tree is mounted in. Resolve engine services from the context:

import { Scene, SceneManagerKey } from "@yagejs/core";
import { Button, useEngine } from "@yagejs/ui-react";
class PauseScene extends Scene {
readonly name = "pause";
}
function PauseButton() {
const scenes = useEngine().resolve(SceneManagerKey);
return (
<Button onClick={() => void scenes.push(new PauseScene())} width={100} height={40}>
Pause
</Button>
);
}

useStore is the single React entry for every reactive primitive — counters, records, maps, sets, lists, single-cell values, and compound stores. One overload per shape, plus a selector escape hatch. Dispatch is symbol-driven (each shape carries a [STATE_KIND] brand from @yagejs/core).

A store created at module scope is for state that @yagejs/save persists, such as settings or unlocked levels. The score and lives of the current run live in a component on a host entity instead. Read those with useSceneSelector, as State Management shows.

import { createRecord } from "@yagejs/core";
import { Text, useStore } from "@yagejs/ui-react";
// Saved settings: a plain record (object with shallow merge)
const settings = createRecord({ default: () => ({ volume: 0.8, subtitles: true }) });
// Write from a component or an event handler
settings.set({ volume: 0.5 });
// Read from React — auto-rerenders on change
function VolumeLabel() {
const { volume } = useStore(settings);
return <Text style={{ fontSize: 16 }}>{`Volume: ${Math.round(volume * 100)}%`}</Text>;
}

Each useStore(source) returns the source’s natural snapshot:

import {
createCounter,
createList,
createMap,
createRecord,
createSet,
createStore,
createValue,
} from "@yagejs/core";
import { useStore } from "@yagejs/ui-react";
const record = createRecord({ default: () => ({ score: 0 }) });
const counter = createCounter();
const map = createMap<string, number>();
const set = createSet<string>();
const list = createList<string>();
const value = createValue({ default: "idle" });
const compound = createStore((s) => ({ gold: s.counter() }));
useStore(record); // ReactiveRecord<T> → Readonly<T>
useStore(counter); // ReactiveCounter → number
useStore(map); // ReactiveMap<K, V> → Array<[K, V]>
useStore(set); // ReactiveSet<K> → K[]
useStore(list); // ReactiveList<T> → T[]
useStore(value); // ReactiveValue<T> → T
useStore(compound); // ReactiveStore<L> → encoded snapshot

Compounds returned by createStore are accepted too — useStore(compound) returns the encoded snapshot of the whole tree. For finer subscription granularity read individual leaves:

import { createStore } from "@yagejs/core";
import { Text, useStore } from "@yagejs/ui-react";
const game = createStore((s) => ({
gold: s.counter({ default: 0 }),
inventory: s.map<string, number>(),
}));
function HUD() {
const gold = useStore(game.gold); // number
const entries = useStore(game.inventory); // Array<[string, number]>
return <Text>{`${gold} gold · ${entries.length} items`}</Text>;
}

For partial reads, use the selector escape hatch. The selector receives the source itself, not a snapshot — call its accessors inside:

import { createRecord } from "@yagejs/core";
const store = createRecord({ default: () => ({ position: { x: 0, y: 0 } }) });
// One map entry only — re-renders only when "moonleaf" changes
const moonleafQty = useStore(game.inventory, (m) => m.get("moonleaf") ?? 0);
// Custom equality check
const pos = useStore(
store,
(src) => src.get().position,
(a, b) => a.x === b.x && a.y === b.y,
);

Query ECS entities directly from React:

import { Component } from "@yagejs/core";
import { Text, useQuery } from "@yagejs/ui-react";
class EnemyTag extends Component {}
function EnemyCount() {
const count = useQuery(
[EnemyTag],
(result) => result.size,
);
return <Text>{`Enemies: ${count}`}</Text>;
}

The query is registered on mount and released when the component unmounts, so mounting and unmounting <EnemyCount> repeatedly (e.g. across scene switches) does not accumulate live queries.

The query re-evaluates each frame and only triggers a re-render when the selector result changes.

Read arbitrary scene state. The selector runs every frame, and the component re-renders only when the result changes:

import { Text, useSceneSelector } from "@yagejs/ui-react";
function EntityCounter() {
const count = useSceneSelector((scene) => scene.getEntities().size);
return <Text>{`Entities: ${count}`}</Text>;
}

This is how a React HUD reads game state hosted on an entity. The key is the one the host was spawned with:

import { Component, Entity } from "@yagejs/core";
import { Text, useSceneSelector } from "@yagejs/ui-react";
// Spawned in the scene's onEnter: this.spawn(HudEntity, { key: HUD_KEY })
const HUD_KEY = "hud";
class RunProgress extends Component {
coins = 0;
}
class HudEntity extends Entity {
progress!: RunProgress;
setup(): void {
this.progress = this.add(new RunProgress());
}
}
function CoinCounter() {
const coins = useSceneSelector(
(scene) => scene.findByKey<HudEntity>(HUD_KEY)?.progress.coins ?? 0,
);
return <Text>{`Coins: ${coins}`}</Text>;
}
Imperative (@yagejs/ui)React (@yagejs/ui-react)
Best forSimple HUDs, static menusComplex interactive menus, forms
StateManual .setText() callsDeclarative with useState / useStore
LayoutBuilder APIJSX composition
Bundle sizeSmaller (no React)Adds React dependency
Learning curveLower if no React experienceLower if familiar with React

Use the imperative API for simple, mostly-static UI (score counters, health bars). Use React when your UI has complex state, conditional rendering, or many interactive elements.

Coding agents: fetch https://yage.dev/llms.txt first and prefer the Markdown references it links over these HTML pages. This page's Markdown counterpart is /llms/packages/ui-react.md.