Skip to content

Getting Started

This guide serves two audiences:

  1. Contributors setting up the XMachines JS monorepo for development.
  2. Application developers installing XMachines packages into their own project.

Jump to the section that applies to you:


Contributor Setup

Prerequisites

You need the following installed before cloning the repository:

RequirementVersionNotes
Node.js>= 22.0.0Specified in all package.json engines fields
npmbundled with Node 22The project uses npm workspaces — no separate install needed
Gitany recent versionConventional 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 nvm or fnm, install and activate Node 22 before proceeding.

Installation Steps

1. Clone the repository

Terminal window
git clone git@gitlab.com:xmachin-es/xmachines-js.git
cd xmachines-js

2. Install dependencies

Always use npm ci (not npm install) to respect the lockfile exactly:

Terminal window
npm ci

This 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

Terminal window
npm run build

The 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:

Terminal window
npm test

All 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:

Terminal window
npm run devcontainer:up

To customize the container, copy the sample env file:

Terminal window
cp .devcontainer/.env.sample .devcontainer/.env

Available variables:

VariableDefaultDescription
OPENCODE_EXPERIMENTAL_OXFMTfalseEnables experimental oxfmt inside the opencode UI
OPENCODE_PORT_MAPPING4096Docker 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:

  1. Run npm run build first — some packages need their dist/ present even in development.
  2. Confirm your package’s vitest.config.ts uses defineXmVitestConfig from @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:

Terminal window
npx patch-package <package-name>

Lint errors about import extensions

All TypeScript imports must end with .js, even for .ts source files:

// ✅ Correct
import { PlayError } from "./errors.js";
// ❌ Wrong — will fail at runtime
import { 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:


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:

Terminal window
npm install xstate @xmachines/play-xstate @xmachines/play-actor @xmachines/play-signals
PackageRole
xstateXState v5 state machine engine (peer dependency)
@xmachines/play-xstatedefinePlayer(), PlayerActor, routing helpers
@xmachines/play-actorAbstract actor base class and interface types
@xmachines/play-signalsTC39 Signals polyfill (Signal.State, Signal.Computed, watchSignal)

Step 2: Install a router adapter (pick one)

Terminal window
# 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 v7
npm install @xmachines/play-vue-router # Vue Router 4.x/5.x
npm 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 # SvelteKit
npm install @xmachines/play-svelte-spa-router # Svelte SPA Router

Step 3: Install a view renderer (pick one)

Terminal window
npm install @xmachines/play-react # React 18/19
npm install @xmachines/play-vue # Vue 3
npm install @xmachines/play-solid # SolidJS
npm install @xmachines/play-svelte # Svelte 5
npm install @xmachines/play-dom # Vanilla DOM

Core 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 point
const 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 factory
const createPlayer = definePlayer({ machine });
// 4. Create and start an actor
const actor = createPlayer();
actor.start();
// 5. Read state
console.log(actor.getSnapshot().value); // "off"
console.log(actor.state.get().value); // "off" (TC39 Signal)
// 6. Send events — the machine's guards decide all transitions
actor.send({ type: "toggle" });
console.log(actor.getSnapshot().value); // "on"
// 7. Cleanup
actor.stop();

Key rules:

  • Always use setup({ types }) before createMachine — never bare createMachine from xstate.
  • Use setup.assign(...) for context mutations — not the bare assign from 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 Signal
console.log(actor.currentRoute.get()); // "/"
// Navigate — actor guards validate the transition
actor.send({ type: "play.route", to: "#about" });
console.log(actor.currentRoute.get()); // "/about"
// Attempt a protected route — always guard redirects to login
actor.send({ type: "play.route", to: "#dashboard" });
console.log(actor.getSnapshot().value); // "login" (guard fired)
// Navigate with params
actor.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 idformatPlayRouteTransitions throws MissingStateIdError if absent.
  • Send play.route events with to: "#stateId" — always use the id field prefixed with #, never raw URL paths.
  • The machine context must include params: Record<string, string> and query: Record<string, string>.
  • Use always guards 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 example
import { 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 references
const 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 unload
window.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

App.vue
<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 id
home: { meta: { route: "/" } }
// ✅ Correct
home: { 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 component
const 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:

Terminal window
node --version

Key Concepts Reference

TermDescription
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.stateSignal.State<Snapshot> — TC39 Signal for reactive state observation
actor.currentRouteSignal.Computed<string | null> — resolved URL from active state’s meta.route
actor.currentViewSignal.State<PlaySpec | null> — view spec from active state’s meta.view
formatPlayRouteTransitionsGenerates play.route handlers from id + meta.route state pairs
play.route eventNavigation event — to: "#stateId", optional params, query
always guardProtects states — fires on entry before any event, even on direct URL access
extractMachineRoutesExtracts a RouteTree from a state machine — used by framework-integrated router adapters
createRouteMapFromTreeBuilds a RouteMap from a RouteTree for bidirectional state ID ↔ URL lookups
connectRouterConnects a vanilla DOM router to an actor — returns a disconnect cleanup function
PlayRouterProviderReact component that connects a PlayerActor to TanStack React Router

Next Steps