React integration
Mount an ExoJS Application inside a React tree and drive scenes declaratively with the @codexo/exojs-react bindings.
React integration
The official @codexo/exojs-react package lets you host an ExoJS Application inside a React component tree: it owns the canvas, manages the app lifecycle for you, switches Scenes declaratively, and lets React HUD overlays read the running app through context.
It is a plain React binding, not an engine extension — it registers no engine bindings and nothing to wire into ApplicationOptions. You use it from .tsx files alongside the rest of your React UI.
Note: This package sits next to the core engine and needs React as a peer. Install all three:
npm install @codexo/exojs @codexo/exojs-react react
@codexo/exojsandreact(>= 18) are peer dependencies;react-domis the usual host renderer. The package ships pre-built ESM with type declarations and type-checks against both@types/react18 and 19.
Two layers
The package is intentionally split into two layers — pick the one that matches how much DOM control you want:
useExoApplication— headless. A hook that creates and owns theApplicationand binds it to a<canvas>you render. It produces no DOM of its own, so you keep full control over the canvas element, its container, and its styling.<ExoCanvas>— batteries-included. A component that renders a positioned wrapper<div>plus a React-managed<canvas>, and provides the app to descendants via context. HUD overlays and the declarative scene API work out of the box.
<ExoCanvas> is built on top of useExoApplication; everything below the scene API applies to both.
The headless hook
useExoApplication(options?, onReady?) returns the app and a ref to attach to your own canvas:
import { useExoApplication } from '@codexo/exojs-react';
function Game() {
const { app, canvasRef } = useExoApplication({ canvas: { width: 800, height: 600 } });
// `app` is null until the canvas is mounted; render it however you like.
return <canvas ref={canvasRef} className="game-surface" />;
}The hook returns { app, canvasRef }: app is the Application (or null until the canvas mounts and the app is created), and canvasRef is a stable ref you attach to the <canvas> the app should bind to. The optional onReady callback fires once each time an app is created.
Lifecycle and reactivity
options is an ExoApplicationOptions — the same shape as ApplicationOptions, but the canvas.element / canvas.mount fields are managed for you (the hook binds the app to the canvas it references). You still pass canvas.width, canvas.height, canvas.sizing, clearColor, backend, and so on.
The hook treats most options as captured-at-creation, but live-syncs a few without tearing the app down:
- The app is recreated only when the render
backendchanges — WebGL2 ↔ WebGPU cannot be hot-swapped, so this is the one identity option. canvas.width/canvas.heightare applied live viaapp.resize(...).clearColoris applied live via theapp.clearColorsetter (keyed on its channel values, so a freshColorwith identical channels does not re-assign).- Options without a live setter (
canvas.pixelRatio,seed,extensions, …) are captured at creation. Change thebackendor remount to apply them. canvas.sizingis captured at creation as well. A sizing policy is an object, so a fresh instance on every render would detach and re-attach the previous one each time; assignapp.sizingyourself to switch strategies at runtime.
On unmount the hook calls app.destroy(). The engine never removes a canvas it did not create, so React stays the sole owner of the <canvas> element.
Don't fight a sizing policy with inline style
With no canvas.sizing the engine only ever writes the base resolution onto the canvas, so you may style it freely. A sizing policy owns the canvas’s width/height styles — size the wrapper and let the policy observe it, rather than setting your own inline style on the canvas.
The batteries-included canvas
<ExoCanvas> renders a position: relative wrapper <div> containing the canvas, and provides the app via context so descendants (HUD, the scene API) can reach it. Because the wrapper is positioned, absolutely-positioned children sit over the canvas with no extra setup.
import { ExoCanvas } from '@codexo/exojs-react';
function Game() {
return (
<ExoCanvas options={{ canvas: { width: 1280, height: 720 } }} style={{ width: 1280, height: 720 }}>
<div style={{ position: 'absolute', top: 8, left: 8 }}>HUD overlay</div>
</ExoCanvas>
);
}Props:
options— theExoApplicationOptionsforwarded to the app (same reactivity as above).onReady— called once each time the app is (re)created.- Layout props (
style,className, and any other<div>attributes) apply to the wrapper, which is the element acanvas.sizingpolicy measures. Size the wrapper to size the canvas. canvasProps— forwarded to the inner<canvas>(its ownstyle/className);ref,width, andheightare managed by the engine and cannot be set here.children— rendered as an overlay once the app exists, with the app available via context.
Changing backend recreates the whole app
Every reactive option re-syncs in place except one: switching backend tears the app down and rebuilds it, because WebGL2 and WebGPU can’t hot-swap within a single instance. The active scene, GPU resources, and frame count all reset — treat backend as fixed for an app’s lifetime.
For full control with no wrapper element, drop down to useExoApplication.
Declarative scenes
<Scenes> switches the one active scene by name. Declare each scene with <Scene> and select the active one through the active prop. The first activation calls app.start(scene) (which initializes the backend and starts the frame loop); later switches call app.scenes.change(scene, …) with the optional transition.
The scene classes you reference are ordinary ExoJS scenes — the React layer only decides which one is active:
export class TitleScene extends Scene {}
export class GameScene extends Scene {
public score = 0;
}function Game({ screen }: { screen: 'title' | 'game' }) {
return (
<ExoCanvas options={{ canvas: { width: 1280, height: 720 } }} style={{ width: 1280, height: 720 }}>
<Scenes active={screen} transition={new FadeSceneTransition({ duration: Time.seconds(0.3) })}>
<Scene name="title" component={TitleScene} />
<Scene name="game" component={GameScene}>
<Hud />
</Scene>
</Scenes>
</ExoCanvas>
);
}Each <Scene> takes a unique name, the scene component class to instantiate, and optional children that render as the active scene’s overlay. The transition (a SceneTransition instance — e.g. new FadeSceneTransition({ duration: Time.seconds(0.3) })) only applies to switches, not the first start. If active matches no <Scene>, the underlying engine scene is left running untouched (there is no public API to clear it mid-lifetime) — only the React-rendered HUD overlay is cleared, and a console warning is logged. This is a caller mismatch (an active name with no matching <Scene>), not a supported “show nothing” path — check active against your actual <Scene name="..."> declarations if you see the warning.
useActiveScene() reads the live scene instance from the nearest <Scenes>, so an overlay can react to scene state:
import { useActiveScene } from '@codexo/exojs-react';
import type { GameScene } from './scenes';
function Hud() {
const scene = useActiveScene<GameScene>();
if (scene === null) return null;
return <div style={{ position: 'absolute', top: 8, left: 8 }}>Score: {scene.score}</div>;
}For the simplest case — a single scene with no switching — use useScene(SceneClass, deps?) instead. It instantiates and activates one scene, returning the instance once it is live (or null while loading), and clears it on unmount or when deps change.
Reaching the app from descendants
Any component rendered inside <ExoCanvas> can read the running app:
useExoApp()returns theApplicationand throws an actionable error if there is no<ExoCanvas>ancestor — use it in components that require the app.useExoContext()returnsApplication | null(no throw) for optional access.ExoContextis the underlying context object, exported for advanced use (testing, custom providers).
app.frameCount is a plain getter the engine updates on its own frame loop — reading it alone does not make a component re-render. Pair useExoApp() with useSignal() to subscribe to app.onFrame and re-render on every dispatch:
import { useExoApp, useSignal } from '@codexo/exojs-react';
function FrameCounter() {
const app = useExoApp(); // throws if rendered outside <ExoCanvas>
const frameCount = useSignal(app.onFrame, () => app.frameCount);
return <span>Frame: {frameCount}</span>;
}End-to-end
A small app that hosts the canvas, switches between two scenes with a fade, and overlays React HUD on the active scene:
import { FadeSceneTransition, Time } from '@codexo/exojs';
import { ExoCanvas, Scene, Scenes, useActiveScene } from '@codexo/exojs-react';
import { useState } from 'react';
import { GameScene, TitleScene } from './scenes';
function Hud() {
const scene = useActiveScene();
return <div style={{ position: 'absolute', top: 8, left: 8, color: 'white' }}>{scene?.constructor.name}</div>;
}
export function App() {
const [screen, setScreen] = useState<'title' | 'game'>('title');
return (
<ExoCanvas options={{ canvas: { width: 1280, height: 720 } }} style={{ width: 1280, height: 720 }}>
<Scenes active={screen} transition={new FadeSceneTransition({ duration: Time.seconds(0.3) })}>
<Scene name="title" component={TitleScene}>
<button style={{ position: 'absolute', inset: 0 }} onClick={() => setScreen('game')}>
Start
</button>
</Scene>
<Scene name="game" component={GameScene}>
<Hud />
</Scene>
</Scenes>
</ExoCanvas>
);
}React owns the screen state and the HUD; ExoJS owns the canvas, the renderer, and the per-frame loop. The two stay cleanly separated.
Where to look next
- Package README:
@codexo/exojs-react— install, the export table, and the reactivity model. - Scenes & lifecycle: the Scenes & lifecycle chapter explains
app.start,app.scenes.change, transitions, and the hooks the React layer drives for you. - API reference: the
ApplicationandScenepages document the underlying engine surface the bindings wrap.

