Skip to content

@xmachines/play-dom

API / @xmachines/play-dom

Vanilla DOM renderer for XMachines Play architecture with signal-driven rendering.

License: MIT Version

Installation

Terminal window
pnpm add @xmachines/play-dom

Peer dependencies:

Terminal window
pnpm add xstate @xstate/store @xmachines/json-render-core @xmachines/json-render-dom @xmachines/json-render-xstate

Quick Start

import { createRenderer, schema } from "@xmachines/play-dom";
import { definePlayer } from "@xmachines/play-xstate";
import { defineCatalog } from "@xmachines/json-render-core";
import { createMachine } from "xstate";
import { z } from "zod";
import type { ComponentFn } from "@xmachines/play-dom";
// 1. Define a catalog
const catalog = defineCatalog(schema, {
components: {
Home: { props: z.object({ title: z.string() }) },
Login: { props: z.object({ title: z.string(), username: z.string().optional() }) },
},
actions: {
login: { params: z.object({ username: z.string() }) },
logout: {},
},
});
// 2. Implement components
const Home: ComponentFn<typeof catalog, "Home"> = ({ props }) => {
const el = document.createElement("section");
el.textContent = props.title;
return el;
};
const Login: ComponentFn<typeof catalog, "Login"> = ({ props, on }) => {
const el = document.createElement("section");
const btn = document.createElement("button");
const submit = on("submit");
btn.addEventListener("click", () => submit.emit());
el.append(btn);
return el;
};
// 3. Build the factory once (module scope)
const mount = createRenderer(catalog, { Home, Login });
// 4. Create and start an actor — states carry meta.view specs naming catalog components
const machine = createMachine({
initial: "home",
states: {
home: {
on: { "goto.login": "login" },
meta: {
view: {
root: "root",
elements: { root: { type: "Home", props: { title: "Home" }, children: [] } },
},
},
},
login: {
meta: {
view: {
root: "root",
elements: { root: { type: "Login", props: { title: "Login" }, children: [] } },
},
},
},
},
});
const actor = definePlayer({ machine })();
actor.start();
// 5. Mount when actor and container are ready
const disconnect = mount(actor, document.getElementById("app")!);
// 6. Cleanup on teardown
disconnect();

Usage

createRenderer is the simplest integration path. Call it one time at module scope, with your catalog and your component map. Then call the mount function that it returns, one time for each pair of an actor and a container.

import { createRenderer, schema } from "@xmachines/play-dom";
import { defineCatalog } from "@xmachines/json-render-core";
const catalog = defineCatalog(schema, {/* ... */});
const mount = createRenderer(catalog, { MyComponent });
// actor from the Quick Start
const disconnect = mount(actor, document.getElementById("app")!);
// Returns a cleanup function — call it to stop rendering and clear the container.
disconnect();

createPlayUI — the complete factory with every option

Use createPlayUI when you need a render error handler, a fallback element, a navigation integration, a computed function, or a custom check. Use it also when your code needs the registryResult value, for example for executeAction.

The factory holds the factory options (functions, validationFunctions, navigate, onRenderError, and fallback) from the moment of its creation, and it applies them on every mount() call. Give the mount options (store and loading) to mount() itself.

import { defineRegistry, createPlayUI, schema } from "@xmachines/play-dom";
import { defineCatalog } from "@xmachines/json-render-core";
const catalog = defineCatalog(schema, {/* ... */});
// Home, Login, actor from the Quick Start
const registryResult = defineRegistry(catalog, {
components: { Home, Login },
actions: {
login: async (params, setState) => {
/* ... */
},
logout: async () => actor.send({ type: "auth.logout" }),
},
});
const mount = createPlayUI(registryResult, {
onRenderError: console.error,
fallback: document.getElementById("loading")!,
navigate: (path) => myRouter.push(path),
functions: {
fullName: (args) => `${args.first} ${args.last}`,
},
});
const disconnect = mount(actor, document.getElementById("app")!);
disconnect();

PlayRenderer — class-based lifecycle control

Use PlayRenderer directly when you need the explicit connect() and disconnect() control, or when you integrate the renderer into a system that controls its life itself.

import { PlayRenderer, defineRegistry, schema } from "@xmachines/play-dom";
// catalog, actor from the Quick Start; components/actions as in the createPlayUI example
const registryResult = defineRegistry(catalog, { components, actions });
// container: your mount element, e.g. document.getElementById("app")!
const renderer = new PlayRenderer(container, actor, registryResult.registry, { registryResult });
renderer.connect();
// Later:
renderer.disconnect();

Controlled store mode. Give the renderer an external StateStore. The renderer then shares the state with the other parts of your application:

import { createAtom } from "@xstate/store";
import { xstateStoreStateStore } from "@xmachines/json-render-xstate";
const atom = createAtom<Record<string, unknown>>({ username: "" });
const store = xstateStoreStateStore({ atom });
// container, actor, registryResult from the previous example
const renderer = new PlayRenderer(container, actor, registryResult.registry, {
registryResult,
store,
});
renderer.connect();

Provider Options

Every entry point (createPlayUI and PlayRenderer) accepts the same UI-provider options, through UIProviderOptions. Each render pass puts the options into DomRenderContext. A component implementation therefore reads them at ctx.ctx.*.

functions — named compute functions for $computed prop expressions

This option permits a dynamic prop value of the form { "$computed": "name", "args": {...} } in a spec. Each function receives the resolved args object, and it returns the computed value.

const mount = createPlayUI(registryResult, {
functions: {
fullName: (args) => `${args.first} ${args.last}`,
formatDate: (args) => new Date(args.iso as string).toLocaleDateString(),
},
});

Without functions, every $computed expression resolves to undefined. Nothing throws, and the old behavior stays.

validationFunctions — custom field validation

This option gives the named check functions for a field check inside a component. Each function receives (value, args?). It returns true for a valid value, or false for an invalid value.

The DOM renderer has no ValidationProvider tree, and in this it is different from the framework renderers. Each component must call the check itself, with runValidationCheck or runValidation from @xmachines/json-render-core. Give ctx.ctx.validationFunctions to the call as customFunctions.

import { runValidationCheck } from "@xmachines/json-render-core";
const mount = createPlayUI(registryResult, {
validationFunctions: {
isEven: (value) => typeof value === "number" && value % 2 === 0,
phoneNumber: (value) => /^\+?[\d\s\-()]{7,}$/.test(String(value)),
},
});
// Inside a ComponentFn (catalog from the Quick Start):
const Login: ComponentFn<typeof catalog, "Login"> = ({ ctx }) => {
const someValue = 42; // the value to validate, e.g. read from an input
const result = runValidationCheck(
{ type: "isEven", message: "must be even" },
{ value: someValue, stateModel: {}, customFunctions: ctx.ctx.validationFunctions },
);
// result.valid, result.message
return null;
};

The renderer calls this callback when an action binding resolves with onSuccess: { navigate: "/path" }. The callback receives the resolved path string as its only argument. Use it with every router:

// React Router / TanStack Router / any push-based router:
const mount = createPlayUI(registryResult, {
navigate: (path) => myRouter.push(path),
});

With a spec binding:

{
"on": {
"click": {
"action": "submitForm",
"onSuccess": { "navigate": "/dashboard" }
}
}
}

submitForm completes without an error, and the renderer then calls navigate("/dashboard").

A component implementation can also read the function at ctx.ctx.navigate. Use this when a navigation must start outside an action binding.

onRenderError — unified error handler

The handler receives (error, name) for three different classes of error:

  • A component render error — a ComponentFn throws synchronously during renderSpec. name is then the catalog component name, for example "Home".
  • An action handler rejection on the emit path — an ActionFn throws, or it returns a rejected promise, during emit(). name is then the catalog action name, for example "submitForm".
  • An action handler rejection on the watch path — an ActionFn rejects during a watch binding callback. name is then the catalog action name.
const mount = createPlayUI(registryResult, {
onRenderError: (err, name) => {
// Route to your application's error tracking
Sentry.captureException(err, { extra: { name } });
},
});

The order of the arguments is the same as in the RenderErrorHandler type of @xmachines/json-render-core: the error first, the name second. The framework renderers (@xmachines/json-render-solid and @xmachines/json-render-react) use the same order.

Without onRenderError, the renderer writes all three types of error to console.error, then stops them. No exception goes to the caller, and no promise rejection stays unhandled.

A component implementation can also read the handler at ctx.ctx.onRenderError. A component therefore sends its own internal errors through the same channel:

// catalog from the Quick Start; doSomethingRisky: your render logic that may throw
const Home: ComponentFn<typeof catalog, "Home"> = ({ ctx }) => {
try {
const el = doSomethingRisky();
return el;
} catch (err) {
ctx.ctx.onRenderError?.(err, "Home");
return null;
}
};

API Summary

XMachines Layer

ExportKindDescription
createRenderer(catalog, components)functionThe one-call factory. It returns mount(actor, container, options?) → disconnect
createPlayUI(registryResult, options?)functionThe complete factory. It returns a MountFn
PlayRendererclassThe renderer class, with a connect() and disconnect() lifecycle
defineRegistry(catalog, options)functionBuild a catalog-typed DomRegistry with typed handlers
renderSpec(...)functionThe pure low-level Spec → DOM renderer
schemaconstThe @xmachines/json-render-dom schema — pass to defineCatalog()

Key Types

TypeDescription
ComponentFn<C, K>Catalog-typed component function — returns HTMLElement | Text | null
ComponentContext<C, K>The context of each component: props, children, emit, on, bindings, and ctx
ActionFn<C, K>Catalog-typed action function — receives (params, setState, state)
EventHandleThe handle that on(eventName) returns. It has emit(), shouldPreventDefault, and bound
SetStateState updater: (prev => next) => void
DefineRegistryResultThe result of defineRegistry. It has registry, handlers, and executeAction
PlayDomOptionsOptions for PlayRenderer — extends UIProviderOptions
CreatePlayUIOptionsOptions for createPlayUI — extends UIProviderOptions, adds fallback
MountOptionsPer-mount options for MountFn: store, loading
MountFnThe mount function that createPlayUI returns: (actor, container, options?) → disconnect
UIProviderOptionsShared options: functions, validationFunctions, navigate, onRenderError
BaseComponentProps<P>Catalog-agnostic component props for shared component libraries
DomRegistryRaw registry type: Record<string, DomComponentRenderer>
DomSchemaType of the schema export
ComputedFunctionThe type of a named compute function for the functions option

Rendering Behavior

  • The first render is synchronous — the renderer fills the container before connect() returns.
  • A signal-driven render waits for a microtaskwatchSignal puts each update on the next tick of the microtask queue.
  • A null view clears the container. The renderer can show a fallback element on the first mount, when the view is null. It does not add that element again when the view returns to null after a view that was not null.
  • A second connect() is safe — a connect() call on a connected renderer disconnects it first.
  • disconnect() clears the container and cancels every signal watcher and store watcher.

Testing

Terminal window
# Run all tests (jsdom environment)
pnpm test
# Run with coverage
pnpm run test:coverage

The tests are in test/. They use Vitest in a jsdom environment. The coverage thresholds are 80% for lines, functions, branches, and statements.

@xmachines/play-dom — the vanilla DOM renderer of the XMachines Play architecture.

The public API has two layers:

The XMachines layer, in this package:

  • createRenderer() — the one-call factory. It returns mount(actor, container, options?) → disconnect
  • createPlayUI() — the complete factory, with every option. It returns a MountFn
  • PlayRenderer — the renderer class, with a connect() and disconnect() lifecycle
  • PlayDomOptions, CreatePlayUIOptions, MountFn, MountOptions

The json-render layer, re-exported from @xmachines/json-render-dom:

  • defineRegistry — it builds a catalog-typed DomRegistry
  • renderSpec — the pure Spec → DOM renderer. It uses resolveElementProps of the core
  • ComponentFn — the catalog-typed type of a component function
  • ComponentContext — the catalog-typed render context: props, emit, on, children, bindings
  • ComponentRegistry — the catalog-typed input type of the registry
  • DomComponentRenderer — the raw renderer type of one element
  • DomRegistry — the raw registry type
  • DomRenderContext — the raw render context
  • EventHandle — the event handle that on() returns
  • SetState — the state updater function of an ActionFn
  • CatalogHasActions — the conditional type. It is true when the catalog declares an action
  • BaseComponentProps — the base props type of a catalog component definition

Classes

Interfaces

Type Aliases

Variables

Functions