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.
Prerequisites
Section titled “Prerequisites”Install both @yagejs/ui and @yagejs/ui-react, plus React:
npm install @yagejs/ui @yagejs/ui-react reactBoth 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
Section titled “UIRoot”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 uproot.offset; // { x: 0, y: -120 }Both values must be finite. A NaN or an infinity throws and names the
argument.
Letting HUD input reach gameplay
Section titled “Letting HUD input reach gameplay”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.
Entity-anchored React UI
Section titled “Entity-anchored React UI”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).
JSX Components
Section titled “JSX Components”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> );}Buttons with rich content
Section titled “Buttons with rich content”<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 typecheckAvailable Components
Section titled “Available Components”| Component | Description |
|---|---|
<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 |
Removing a prop between renders
Section titled “Removing a prop between renders”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.
The bg shorthand
Section titled “The bg shorthand”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.
Absolute positioning
Section titled “Absolute positioning”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> — Z-axis overlay primitive
Section titled “<ZStack> — Z-axis overlay primitive”<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>Scale, rotation and draw order
Section titled “Scale, rotation and draw order”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.
Hover events
Section titled “Hover events”<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 existingonClick, so reach for these when enter and leave need separate handlers.onHover(hovering)— the convenience form: called withtrueon enter andfalseon 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.
Keyboard and gamepad focus
Section titled “Keyboard and gamepad focus”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> — hover-driven floating label
Section titled “<Tooltip> — hover-driven floating label”<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>| Prop | Description |
|---|---|
content | Bubble body — string/number auto-wraps in <Text>; nodes for rich content |
placement | side or side-align: "top" (default, center-aligned), "bottom-start", "right-end", … |
offset | Gap in px between trigger and bubble (default 6) |
maxWidth | Cap bubble width (px) — long content wraps instead of running off-screen |
bg / padding / textStyle | Bubble styling (headless — omit for an unstyled bubble) |
opened | Force visibility, bypassing hover (controlled / debug) |
disabled | Render 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.
Headless: useFloating
Section titled “Headless: useFloating”<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.
PixiUI Components
Section titled “PixiUI Components”Wrappers for advanced @pixi/ui widgets:
import { PixiFancyButton, PixiSlider, PixiInput, PixiSelect, PixiRadioGroup,} from "@yagejs/ui-react";Scrolling lists with <ScrollView>
Section titled “Scrolling lists with <ScrollView>”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 / useScene
Section titled “useEngine / useScene”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
Section titled “useStore”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 handlersettings.set({ volume: 0.5 });
// Read from React — auto-rerenders on changefunction 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 → numberuseStore(map); // ReactiveMap<K, V> → Array<[K, V]>useStore(set); // ReactiveSet<K> → K[]useStore(list); // ReactiveList<T> → T[]useStore(value); // ReactiveValue<T> → TuseStore(compound); // ReactiveStore<L> → encoded snapshotCompounds 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" changesconst moonleafQty = useStore(game.inventory, (m) => m.get("moonleaf") ?? 0);
// Custom equality checkconst pos = useStore( store, (src) => src.get().position, (a, b) => a.x === b.x && a.y === b.y,);useQuery
Section titled “useQuery”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.
useSceneSelector
Section titled “useSceneSelector”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>;}When to Use React vs Imperative
Section titled “When to Use React vs Imperative”Imperative (@yagejs/ui) | React (@yagejs/ui-react) | |
|---|---|---|
| Best for | Simple HUDs, static menus | Complex interactive menus, forms |
| State | Manual .setText() calls | Declarative with useState / useStore |
| Layout | Builder API | JSX composition |
| Bundle size | Smaller (no React) | Adds React dependency |
| Learning curve | Lower if no React experience | Lower 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.