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 | >= 24.0.0 | The floor to develop AND to consume. Every package.json engines field declares it. |
| pnpm | via corepack | Enable with corepack enable; the version is pinned by the packageManager field. The project uses pnpm workspaces. |
| 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 pnpm install --frozen-lockfile.
Node.js version manager tip: If you manage multiple Node versions with
nvmorfnm, install and activate Node 24 before proceeding.
Installation Steps
1. Clone the repository
git clone git@gitlab.com:xmachin-es/xmachines-js.gitcd xmachines-js2. Install dependencies
Always use pnpm install --frozen-lockfile (not pnpm install) to respect the lockfile exactly:
corepack enablepnpm install --frozen-lockfileThis installs the exact versions recorded in the lockfile for all workspace packages.
3. Build all packages
pnpm 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:
pnpm 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 24 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:
pnpm 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
pnpm 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 pnpm install --frozen-lockfile 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.
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:
- Architecture — System design, layers, invariants, and data flow
- Contributing — Coding standards, branch conventions, and PR process
- Development — Full development lifecycle: build commands, adding packages, commit conventions
- Testing — 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
>= 24.0.0 - pnpm via corepack (
corepack enable) - 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 six packages plus XState:
pnpm add xstate @xmachines/play-xstate @xmachines/play-actor @xmachines/play-atom @xmachines/play @xmachines/core @xmachines/json-render-core| Package | Role |
|---|---|
xstate | XState v5 state machine engine (peer dependency) |
@xmachines/play-xstate | definePlayer(), PlayerActor, routing helpers |
@xmachines/play-actor | The actor contract: PlayActor, which names no state machine library |
@xmachines/play-atom | The atom primitives (createAtom, watchAtom) |
@xmachines/play | The protocol: PlayEvent, and PlayError on its errors subpath — a peer of every package that reads it |
@xmachines/core | BaseError, the release protocol, and the cache — a peer of @xmachines/play-xstate, @xmachines/play-view, and @xmachines/play-router |
@xmachines/json-render-core | The spec type that PlaySpec extends — a peer of @xmachines/play-view, and of every renderer package |
Step 2: Install a router adapter (pick one)
# Provider pattern (framework-integrated routers)pnpm add @xmachines/play-tanstack-react-router # TanStack Router (React)pnpm add @xmachines/play-tanstack-solid-router # TanStack Router (SolidJS)pnpm add @xmachines/play-react-router # React Router 7/8pnpm add @xmachines/play-vue-router # Vue Router 4.x/5.xpnpm add @xmachines/play-solid-router # SolidJS Router
# connectRouter pattern (framework-agnostic)pnpm add @xmachines/play-dom-router # Vanilla DOM (Browser History API)pnpm add @xmachines/play-sveltekit-router # SvelteKitpnpm add @xmachines/play-svelte-spa-router # Svelte SPA RouterStep 3: Install a view renderer (pick one)
pnpm add @xmachines/play-react # React 18/19pnpm add @xmachines/play-vue # Vue 3pnpm add @xmachines/play-solid # SolidJSpnpm add @xmachines/play-svelte # Svelte 5pnpm add @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" (an atom read)
// 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 atom subscriptions.
Observing the actor
actor.state, actor.currentRoute and actor.currentView are atoms. .get() reads
one time, as above. To react to a change, use
watchAtom, which returns the
release:
import { watchAtom } from "@xmachines/play-atom";
const stop = watchAtom(actor.state, (snapshot) => { console.log("the machine is now:", snapshot.value);});
actor.send({ type: "toggle" }); // the callback runs from a microtask
stop(); // release it, or write `using stop = asDisposable(watchAtom(...))`Three rules cover every use of them:
- Read, never write. The actor owns every atom it publishes.
currentRouteandcurrentVieware DERIVED fromstate, so they carry nosetat all. watchAtomcoalesces. Several writes in one tick reach the callback one time, with the last value.atom.subscribedelivers synchronously instead, inside the write — use it only when you need the notification at that exact moment.- Release on teardown. An atom holds its subscription until you release it. A
framework renderer does this for you; code that calls
watchAtomitself does not.
A renderer needs none of this: @xmachines/play-react
and the other renderers observe currentView for you. Reach for watchAtom when you
write infrastructure, or a debug panel beside the UI.
Understanding Atoms states the whole model.
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, compose, PlayerActor } from "@xmachines/play-xstate";import { formatPlayRouteTransitions } from "@xmachines/play-xstate/routing";import { withRouting } from "@xmachines/play-xstate/routing";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, actor: compose(PlayerActor, withRouting),});const actor = createPlayer();actor.start();
// Read the current route from the atomconsole.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, createRouteMapFromTree } from "@xmachines/play-tanstack-react-router";import { extractMachineRoutes } from "@xmachines/play-router/xstate";// 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: the actor.currentRoute atom → browser URL, and browser URL changes → play.route events sent to the actor.
import { createBrowserHistory, createRouter, connectRouter } from "@xmachines/play-dom-router";import { createRouteMapFromTree } from "@xmachines/play-router";import { extractMachineRoutes } from "@xmachines/play-router/xstate";
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 });
// Call teardown() when the app ends before the page does, for example when the component// that holds it unmounts. At the end of the page, do nothing: the browser frees the page.// A teardown on `beforeunload` also runs when the page goes into the back/forward cache,// and Back then shows a page that does not respond.function teardown(): void { 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 "@xmachines/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 "@xmachines/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 "@xmachines/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 "@xmachines/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")!);
// Call disconnect() when the app ends before the page does, for example when the component// that holds it unmounts. At the end of the page, do nothing: the browser frees the page.Common Setup Issues (Application Developers)
Query parameters missing from derived URLs
Symptom: currentRoute renders the path but the ?query=... string from a
play.route event never appears. No error is raised — a context without a
query field builds a query-less URL, exactly like query: {}.
Fix: With formatPlayRouteTransitions
nothing is needed — the generated transitions assign event.query to context
on every navigation. A machine that handles play.route by hand must do that
assignment itself:
on: { "play.route": { actions: assign({ params: ({ event }) => event.params ?? {}, query: ({ event }) => event.query ?? {}, }), },}(Older releases threw MissingQueryContextError at construction for a
routing-aware context without a query field. That class is gone: the generated
play.route transitions assign query on every navigation, so the loss it
guarded against cannot happen.)
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 the atom package is not resolved.
Fix: Use Node.js >= 24.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 | Atom<Snapshot> — the atom for reactive state observation |
actor.currentRoute | ReadonlyAtom<string | null> — resolved URL from active state’s meta.route |
actor.currentView | ReadonlyAtom<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
- Inspecting a Running Actor — Attaching the Stately inspector to see the machine, its transitions, and its context live
- Architecture — System design, layers, invariants, and data flow
- Play RFC — Complete architectural specification
- API Reference — Auto-generated API docs for all packages
- Examples — Basic state machine, form validation, routing pattern demos
- Contributing — Coding standards, branch conventions, and PR process (contributors)