Guide

GuideIntegrationsReact integration

React integration

Mount an ExoJS Application inside a React tree and drive scenes declaratively with the @codexo/exojs-react bindings.

Intermediate~6 min read

What you'll learn

  • mount an ExoJS Application in a React tree with useExoApplication or ExoCanvas
  • switch scenes declaratively with <Scenes>
  • read the running app and active scene from React overlays

Before you start

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/exojs and react (>= 18) are peer dependencies; react-dom is the usual host renderer. The package ships pre-built ESM with type declarations and type-checks against both @types/react 18 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 the Application and 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:

packages/exojs-react/examples/guides/headless-hook.tsx
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 backend changes — WebGL2 ↔ WebGPU cannot be hot-swapped, so this is the one identity option.
  • canvas.width / canvas.height are applied live via app.resize(...).
  • clearColor is applied live via the app.clearColor setter (keyed on its channel values, so a fresh Color with identical channels does not re-assign).
  • Options without a live setter (canvas.pixelRatio, seed, extensions, …) are captured at creation. Change the backend or remount to apply them.
  • canvas.sizing is 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; assign app.sizing yourself 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.

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.

packages/exojs-react/examples/guides/exo-canvas.tsx
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 — the ExoApplicationOptions forwarded 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 a canvas.sizing policy measures. Size the wrapper to size the canvas.
  • canvasProps — forwarded to the inner <canvas> (its own style/className); ref, width, and height are managed by the engine and cannot be set here.
  • children — rendered as an overlay once the app exists, with the app available via context.

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:

packages/exojs-react/examples/guides/scenes.ts
export class TitleScene extends Scene {}
export class GameScene extends Scene {
  public score = 0;
}
packages/exojs-react/examples/guides/scene-switching.tsx
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:

packages/exojs-react/examples/guides/active-scene-hud.tsx
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 the Application and throws an actionable error if there is no <ExoCanvas> ancestor — use it in components that require the app.
  • useExoContext() returns Application | null (no throw) for optional access.
  • ExoContext is 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:

packages/exojs-react/examples/guides/frame-counter.tsx
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:

packages/exojs-react/examples/guides/end-to-end.tsx
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 Application and Scene pages document the underlying engine surface the bindings wrap.