Getting Started
This guide serves two audiences:
- Contributors setting up the XMachines JS monorepo for development.
- Application developers installing XMachines packages into their own project.
Jump to the section that applies to you:
- Contributor Setup — Clone, install, build, and test the monorepo
- Application Developer Quickstart — Install packages, define a machine, connect router and renderer
Contributor Setup
Prerequisites
You need the following installed before cloning the repository:
| Requirement | Version | Notes |
|---|---|---|
| Node.js | >= 22.0.0 | Specified in all package.json engines fields |
| npm | bundled with Node 22 | The project uses npm workspaces — no separate install needed |
| Git | any recent version | Conventional commit format is enforced by CI |
No global TypeScript install is needed — it is installed as a dev dependency via npm ci.
Node.js version manager tip: If you manage multiple Node versions with
nvmorfnm, install and activate Node 22 before proceeding.
Installation Steps
1. Clone the repository
git clone git@gitlab.com:xmachin-es/xmachines-js.gitcd xmachines-js2. Install dependencies
Always use npm ci (not npm install) to respect the lockfile exactly:
npm ciThis installs all workspace dependencies and automatically runs patch-package via the postinstall hook to apply the patches in patches/. The patch output is expected and required — do not skip it.
3. Build all packages
npm run buildThe root tsc --build command uses TypeScript project references to compile all packages in correct dependency order. Build outputs go to each package’s dist/ directory (gitignored).
First Run
Verify your setup is working end-to-end:
npm testAll tests should pass on a freshly cloned and built repository. If they do, your environment is correctly configured.
Dev Container (Optional)
A fully configured dev container is provided at .devcontainer/. It uses Docker Compose with a Node 22 Bookworm base image and includes Docker-outside-of-Docker, Claude Code, and OpenCode pre-installed.
VS Code: Open the repository and choose Reopen in Container when prompted.
CLI:
npm run devcontainer:upTo customize the container, copy the sample env file:
cp .devcontainer/.env.sample .devcontainer/.envAvailable variables:
| Variable | Default | Description |
|---|---|---|
OPENCODE_EXPERIMENTAL_OXFMT | false | Enables experimental oxfmt inside the opencode UI |
OPENCODE_PORT_MAPPING | 4096 | Docker port mapping for the opencode web interface |
Common Setup Issues (Monorepo)
Tests fail with Cannot find module errors
The test suite relies on TypeScript path aliases that resolve to source files. If aliases are not resolving:
- Run
npm run buildfirst — some packages need theirdist/present even in development. - Confirm your package’s
vitest.config.tsusesdefineXmVitestConfigfrom@xmachines/shared/vitest, which automatically configures the@xmachines/*path aliases.
TypeScript errors about missing types after adding a dependency
Run npm ci again to ensure all types are installed. If you added a workspace-local dependency, also add the correct references entry in the package’s tsconfig.base.json.
patch-package errors during install
The patches in patches/ are version-specific. If you updated a @json-render package, regenerate the patch:
npx patch-package <package-name>Lint errors about import extensions
All TypeScript imports must end with .js, even for .ts source files:
// ✅ Correctimport { PlayError } from "./errors.js";
// ❌ Wrong — will fail at runtimeimport { PlayError } from "./errors";Your editor’s auto-import may omit the extension — fix it manually or configure the editor to add .js automatically.
Build is slow after git clean
TypeScript incremental builds depend on .tsbuildinfo files. After a full clean, these are gone and the initial build will be slower. This is expected.
Next Steps (Contributors)
Once your setup is verified, continue with:
- docs/ARCHITECTURE.md — System design, layers, invariants, and data flow
- CONTRIBUTING.md — Coding standards, branch conventions, and PR process
- packages/docs/contributing/development.md — Full development lifecycle: build commands, adding packages, commit conventions
- packages/docs/contributing/testing.md — Vitest setup, test conventions, coverage thresholds, browser tests
- packages/docs/rfc/ — RFC specifications (read the relevant RFC before implementing any feature)
Application Developer Quickstart
This section covers installing XMachines packages into your own application and wiring together a state machine, router adapter, and view renderer.
Prerequisites
- Node.js
>= 22.0.0 - npm
>= 10.0.0 - TypeScript
>= 5.7(strict mode recommended) - XState
v5(required peer dependency for@xmachines/play-xstate)
All packages are ES modules ("type": "module"). Use .js extensions in all TypeScript imports.
Installation
Step 1: Install the core packages
Every XMachines application needs these three packages plus XState:
npm install xstate @xmachines/play-xstate @xmachines/play-actor @xmachines/play-signals| Package | Role |
|---|---|
xstate | XState v5 state machine engine (peer dependency) |
@xmachines/play-xstate | definePlayer(), PlayerActor, routing helpers |
@xmachines/play-actor | Abstract actor base class and interface types |
@xmachines/play-signals | TC39 Signals polyfill (Signal.State, Signal.Computed, watchSignal) |
Step 2: Install a router adapter (pick one)
# Provider pattern (framework-integrated routers)npm install @xmachines/play-tanstack-react-router # TanStack Router (React)npm install @xmachines/play-tanstack-solid-router # TanStack Router (SolidJS)npm install @xmachines/play-react-router # React Router v7npm install @xmachines/play-vue-router # Vue Router 4.x/5.xnpm install @xmachines/play-solid-router # SolidJS Router
# connectRouter pattern (framework-agnostic)npm install @xmachines/play-dom-router # Vanilla DOM (Browser History API)npm install @xmachines/play-sveltekit-router # SvelteKitnpm install @xmachines/play-svelte-spa-router # Svelte SPA RouterStep 3: Install a view renderer (pick one)
npm install @xmachines/play-react # React 18/19npm install @xmachines/play-vue # Vue 3npm install @xmachines/play-solid # SolidJSnpm install @xmachines/play-svelte # Svelte 5npm install @xmachines/play-dom # Vanilla DOMCore Actor (No Router, No Renderer)
The minimum viable XMachines actor — no framework dependencies:
import { setup } from "xstate";import { definePlayer } from "@xmachines/play-xstate";
// 1. Declare types with setup() — the XState v5 typed entry pointconst appSetup = setup({ types: { context: {} as { count: number }, events: {} as { type: "toggle" }, input: {} as undefined, },});
// 2. Create the machine using setup().createMachine()const machine = appSetup.createMachine({ id: "app", initial: "off", context: { count: 0 }, states: { off: { on: { toggle: { target: "on", actions: appSetup.assign({ count: ({ context }) => context.count + 1 }), }, }, }, on: { on: { toggle: { target: "off", actions: appSetup.assign({ count: ({ context }) => context.count + 1 }), }, }, }, },});
// 3. Create a player factoryconst createPlayer = definePlayer({ machine });
// 4. Create and start an actorconst actor = createPlayer();actor.start();
// 5. Read stateconsole.log(actor.getSnapshot().value); // "off"console.log(actor.state.get().value); // "off" (TC39 Signal)
// 6. Send events — the machine's guards decide all transitionsactor.send({ type: "toggle" });console.log(actor.getSnapshot().value); // "on"
// 7. Cleanupactor.stop();Key rules:
- Always use
setup({ types })beforecreateMachine— never barecreateMachinefrom xstate. - Use
setup.assign(...)for context mutations — not the bareassignfrom xstate. - Call
actor.start()before sending events. - Call
actor.stop()when done to clean up signal subscriptions.
Adding Routing
State machines control navigation through meta.route on states and play.route events. The router is passive infrastructure that observes actor.currentRoute.
Define a routable machine with formatPlayRouteTransitions
formatPlayRouteTransitions auto-generates play.route handlers from id + meta.route state pairs:
import { setup } from "xstate";import { definePlayer, formatPlayRouteTransitions } from "@xmachines/play-xstate";import type { PlayRouteEvent } from "@xmachines/play-router";
const appSetup = setup({ types: { // params and query are REQUIRED for formatPlayRouteTransitions context: {} as { isAuthenticated: boolean; params: Record<string, string>; query: Record<string, string>; }, events: {} as | PlayRouteEvent | { type: "auth.login"; username: string } | { type: "auth.logout" }, input: {} as undefined, },});
const appMachine = appSetup.createMachine( // formatPlayRouteTransitions auto-generates play.route handlers from id + meta.route pairs formatPlayRouteTransitions({ id: "app", initial: "home", context: { isAuthenticated: false, params: {}, query: {} }, states: { home: { id: "home", meta: { route: "/" } }, about: { id: "about", meta: { route: "/about" } }, login: { id: "login", meta: { route: "/login" } }, dashboard: { id: "dashboard", meta: { route: "/dashboard" }, // always guard: redirect to /login if not authenticated always: { guard: ({ context }) => !context.isAuthenticated, target: "login", }, }, profile: { id: "profile", meta: { route: "/profile/:username" } }, }, on: { "auth.login": { target: ".dashboard", guard: ({ context }) => !context.isAuthenticated, actions: appSetup.assign({ isAuthenticated: true, }), }, "auth.logout": { target: ".home", guard: ({ context }) => context.isAuthenticated, actions: appSetup.assign({ isAuthenticated: false }), }, }, }),);
const createPlayer = definePlayer({ machine: appMachine });const actor = createPlayer();actor.start();
// Read the current route via TC39 Signalconsole.log(actor.currentRoute.get()); // "/"
// Navigate — actor guards validate the transitionactor.send({ type: "play.route", to: "#about" });console.log(actor.currentRoute.get()); // "/about"
// Attempt a protected route — always guard redirects to loginactor.send({ type: "play.route", to: "#dashboard" });console.log(actor.getSnapshot().value); // "login" (guard fired)
// Navigate with paramsactor.send({ type: "auth.login", username: "alice" });actor.send({ type: "play.route", to: "#profile", params: { username: "alice" } });console.log(actor.currentRoute.get()); // "/profile/alice"
actor.stop();Routing rules:
- Every routable state must have an
id—formatPlayRouteTransitionsthrowsMissingStateIdErrorif absent. - Send
play.routeevents withto: "#stateId"— always use theidfield prefixed with#, never raw URL paths. - The machine context must include
params: Record<string, string>andquery: Record<string, string>. - Use
alwaysguards to protect states from direct URL access — these fire even on browser back/forward.
Connecting a Router Adapter
Router adapters synchronize actor.currentRoute with the browser URL bidirectionally. There are two integration patterns:
Pattern 1: Provider pattern (React, Vue, SolidJS)
Used with framework-integrated routers like TanStack Router. All three props (actor, router, routeMap) must be stable references — construct them outside JSX or memoize them.
// React + TanStack Router exampleimport { useMemo, useEffect } from "react";import { createRouter, createRootRoute } from "@tanstack/react-router";import { PlayRouterProvider, extractMachineRoutes, createRouteMapFromTree,} from "@xmachines/play-tanstack-react-router";
// Build OUTSIDE of JSX — must be stable referencesconst routeTree = extractMachineRoutes(appMachine);const routeMap = createRouteMapFromTree(routeTree);const router = createRouter({ routeTree: createRootRoute() });
// Actor also created outside the component (or memoized inside)const actor = createPlayer();actor.start();
export function App() { return ( <PlayRouterProvider actor={actor} router={router} routeMap={routeMap} renderer={(currentActor, currentRouter) => ( // Your shell/layout component here <Shell actor={currentActor} /> )} /> );}Pattern 2: connectRouter pattern (Vanilla DOM, SvelteKit, Svelte)
Used with framework-agnostic or server-rendered routers. connectRouter handles bidirectional sync: actor.currentRoute signal → browser URL, and browser URL changes → play.route events sent to the actor.
import { createBrowserHistory, createRouter, connectRouter } from "@xmachines/play-dom-router";import { extractMachineRoutes, createRouteMapFromTree } from "@xmachines/play-router";
const routeTree = extractMachineRoutes(appMachine);const routeMap = createRouteMapFromTree(routeTree);const history = createBrowserHistory({ window });const router = createRouter({ routeTree, history });
const actor = createPlayer();actor.start();
const disconnectRouter = connectRouter({ actor, router, routeMap });
// Cleanup on unloadwindow.addEventListener("beforeunload", () => { disconnectRouter(); router.destroy(); actor.stop();});Connecting a View Renderer
View renderers map meta.view specs from your machine states to real UI components. Each framework package exports a defineRegistry function that binds components and actions to a catalog.
React
import { PlayUIProvider, PlayRenderer, defineRegistry } from "@xmachines/play-react";import { defineCatalog } from "@json-render/core";import { schema } from "@xmachines/play-react";import { z } from "zod";
const catalog = defineCatalog(schema, { components: { Home: { props: z.object({ title: z.string() }) }, Login: { props: z.object({}) }, }, actions: { login: { params: z.object({ username: z.string() }) }, logout: {}, },});
const registryResult = defineRegistry(catalog, { components: { Home: ({ props }) => <section>{props.title}</section>, Login: ({ on }) => <button onClick={() => on("submit").emit()}>Log in</button>, }, actions: { login: async ({ username }) => actor.send({ type: "auth.login", username }), logout: async () => actor.send({ type: "auth.logout" }), },});
function App() { return ( <PlayUIProvider actor={actor} registryResult={registryResult}> <PlayRenderer /> </PlayUIProvider> );}Vue 3
<template> <PlayUIProvider :actor="actor" :registry-result="registryResult"> <PlayRenderer /> </PlayUIProvider></template>
<script setup lang="ts"> import { PlayUIProvider, PlayRenderer, defineRegistry } from "@xmachines/play-vue"; import { defineCatalog } from "@json-render/core"; import { schema } from "@xmachines/play-vue"; import { z } from "zod";
const catalog = defineCatalog(schema, { components: { Home: { props: z.object({ title: z.string() }) }, Login: { props: z.object({}) }, }, actions: { login: { params: z.object({ username: z.string() }) }, logout: {}, }, });
const registryResult = defineRegistry(catalog, { components: { Home: { template: `<section>{{ props.title }}</section>` }, Login: { template: `<button @click="on('submit').emit()">Log in</button>` }, }, actions: { login: async ({ username }) => actor.send({ type: "auth.login", username }), logout: async () => actor.send({ type: "auth.logout" }), }, });</script>SolidJS
import { PlayUIProvider, PlayRenderer, defineRegistry } from "@xmachines/play-solid";import { defineCatalog } from "@json-render/core";import { schema } from "@xmachines/play-solid";import { z } from "zod";
const catalog = defineCatalog(schema, { components: { Home: { props: z.object({ title: z.string() }) }, Login: { props: z.object({}) }, }, actions: { login: { params: z.object({ username: z.string() }) }, logout: {}, },});
const registryResult = defineRegistry(catalog, { components: { Home: ({ props }) => <section>{props.title}</section>, Login: ({ on }) => <button onClick={() => on("submit").emit()}>Log in</button>, }, actions: { login: async ({ username }) => actor.send({ type: "auth.login", username }), logout: async () => actor.send({ type: "auth.logout" }), },});
function App() { return ( <PlayUIProvider actor={actor} registryResult={registryResult}> <PlayRenderer /> </PlayUIProvider> );}Vanilla DOM
import { createPlayUI, defineRegistry, schema } from "@xmachines/play-dom";import { defineCatalog } from "@json-render/core";import { z } from "zod";import type { ComponentFn } from "@xmachines/play-dom";
const catalog = defineCatalog(schema, { components: { Home: { props: z.object({ title: z.string() }) }, Login: { props: z.object({}) }, }, actions: { login: { params: z.object({ username: z.string() }) }, logout: {}, },});
const Home: ComponentFn<typeof catalog, "Home"> = ({ props }) => { const el = document.createElement("section"); el.textContent = props.title; return el;};
const Login: ComponentFn<typeof catalog, "Login"> = ({ on }) => { const el = document.createElement("div"); const btn = document.createElement("button"); btn.textContent = "Log in"; btn.addEventListener("click", () => on("submit").emit()); el.append(btn); return el;};
const registryResult = defineRegistry(catalog, { components: { Home, Login }, actions: { login: async ({ username }) => actor.send({ type: "auth.login", username }), logout: async () => actor.send({ type: "auth.logout" }), },});
const mount = createPlayUI(registryResult);const disconnect = mount(actor, document.getElementById("app")!);
window.addEventListener("beforeunload", () => disconnect());Common Setup Issues (Application Developers)
Missing params / query in context
Error: MissingQueryContextError at runtime when using formatPlayRouteTransitions.
Fix: Add params and query fields to your machine context type and initial value:
context: {} as { params: Record<string, string>; query: Record<string, string>; // ...other fields}// and initialize them:context: { params: {}, query: {}, /* ...other fields */ }Missing id on routable states
Error: MissingStateIdError: State "home" has meta.route "/" but no id.
Fix: Every state with meta.route must declare an explicit id:
// ❌ Missing idhome: { meta: { route: "/" } }
// ✅ Correcthome: { id: "home", meta: { route: "/" } }Actor sends events before start()
Symptom: Events are silently dropped; state never changes.
Fix: Always call actor.start() before actor.send():
const actor = createPlayer();actor.start(); // required before any send()actor.send({ type: "toggle" });routeMap or actor recreated on every render (React/SolidJS)
Symptom: The router bridge disconnects and reconnects on every render.
Fix: Construct stable references outside JSX or memoize them:
// ✅ Built outside the componentconst routeTree = extractMachineRoutes(appMachine);const routeMap = createRouteMapFromTree(routeTree);
// ✅ Or memoized inside the component (React)const routeMap = useMemo(() => createRouteMapFromTree(routeTree), [routeTree]);Wrong Node.js version
Error: SyntaxError: Cannot use import statement in a module or TC39 Signals not available.
Fix: Use Node.js >= 22.0.0. Check with:
node --versionKey Concepts Reference
| Term | Description |
|---|---|
setup({ types }) | XState v5 entry point — declares TypeScript types for context, events, and input |
definePlayer({ machine }) | Creates a factory that produces PlayerActor instances |
actor.start() | Activates the machine — always call before sending events |
actor.send({ type }) | Sends an event; machine guards decide whether a transition occurs |
actor.getSnapshot() | Synchronous read of current state and context |
actor.state | Signal.State<Snapshot> — TC39 Signal for reactive state observation |
actor.currentRoute | Signal.Computed<string | null> — resolved URL from active state’s meta.route |
actor.currentView | Signal.State<PlaySpec | null> — view spec from active state’s meta.view |
formatPlayRouteTransitions | Generates play.route handlers from id + meta.route state pairs |
play.route event | Navigation event — to: "#stateId", optional params, query |
always guard | Protects states — fires on entry before any event, even on direct URL access |
extractMachineRoutes | Extracts a RouteTree from a state machine — used by framework-integrated router adapters |
createRouteMapFromTree | Builds a RouteMap from a RouteTree for bidirectional state ID ↔ URL lookups |
connectRouter | Connects a vanilla DOM router to an actor — returns a disconnect cleanup function |
PlayRouterProvider | React component that connects a PlayerActor to TanStack React Router |
Next Steps
- docs/ARCHITECTURE.md — System design, layers, invariants, and data flow
- packages/docs/rfc/play.md — Complete architectural specification
- packages/docs/api/README.md — Auto-generated API docs for all packages
- packages/docs/examples/ — Basic state machine, form validation, routing pattern demos
- CONTRIBUTING.md — Coding standards, branch conventions, and PR process (contributors)