Skip to content

@xmachines/play-dom

API / @xmachines/play-dom

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

Part of the XMachines Play monorepo.

Installation

Terminal window
npm install @xmachines/play-dom

Peer dependencies:

Terminal window
npm install xstate @xstate/store @json-render/core @json-render/xstate

Quick Start

import { createRenderer, schema } from "@xmachines/play-dom";
import { defineCatalog } from "@json-render/core";
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. Mount when actor and container are ready
const disconnect = mount(actor, document.getElementById("app")!);
// 5. Cleanup on teardown
disconnect();

Usage

createRenderer is the simplest integration path. Call it once at module scope with your catalog and component map, then call the returned mount function for each actor/container pair.

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

createPlayUI — batteries-included factory with full options

Use createPlayUI when you need render error handling, a fallback element, navigation integration, computed functions, or custom validation — or when you need to reference registryResult programmatically (e.g. for executeAction).

Factory-level options (functions, validationFunctions, navigate, onRenderError, fallback) are closed over at creation time and applied on every mount() call. Per-mount options (store, loading) are passed to mount() itself.

import { defineRegistry, createPlayUI, schema } from "@xmachines/play-dom";
import { defineCatalog } from "@json-render/core";
const catalog = defineCatalog(schema, {
/* ... */
});
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 explicit connect() / disconnect() lifecycle control, or when integrating into a system that manages the renderer’s lifetime externally.

import { PlayRenderer, defineRegistry, schema } from "@xmachines/play-dom";
const registryResult = defineRegistry(catalog, { components, actions });
const renderer = new PlayRenderer(container, actor, registryResult.registry, { registryResult });
renderer.connect();
// Later:
renderer.disconnect();

Controlled store mode — supply an external StateStore so the renderer shares state with other parts of your app:

import { createAtom } from "@xstate/store";
import { xstateStoreStateStore } from "@json-render/xstate";
const atom = createAtom({ username: "" });
const store = xstateStoreStateStore({ atom });
const renderer = new PlayRenderer(container, actor, registryResult.registry, {
registryResult,
store,
});
renderer.connect();

Provider Options

All entry points (createPlayUI, PlayRenderer) accept the same set of UI-provider options via UIProviderOptions. These are forwarded into DomRenderContext on every render pass, making them available to component implementations via ctx.ctx.*.

functions — named compute functions for $computed prop expressions

Enables { "$computed": "name", "args": {...} } dynamic prop values in specs. Each function receives the resolved args object and 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, any $computed expression silently resolves to undefined (no throw, backward-compatible).

validationFunctions — custom field validation

Provides named validation functions for inline field validation within components. Functions receive (value, args?) and return true (valid) or false (invalid).

Unlike the framework renderers, the DOM renderer has no automatic ValidationProvider tree. Components must invoke validation explicitly using runValidationCheck / runValidation from @json-render/core, passing ctx.ctx.validationFunctions as customFunctions.

import { runValidationCheck } from "@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:
const MyField: ComponentFn<typeof catalog, "MyField"> = ({ ctx }) => {
const result = runValidationCheck(
{ type: "isEven", message: "must be even" },
{ value: someValue, stateModel: {}, customFunctions: ctx.ctx.validationFunctions },
);
// result.valid, result.message
};

A callback invoked when an action binding resolves with onSuccess: { navigate: "/path" }. The resolved path string is passed as the sole argument. Integrate with any 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" }
}
}
}

When submitForm completes successfully, navigate("/dashboard") is called automatically.

The function is also readable by component implementations directly via ctx.ctx.navigate for cases where navigation needs to be triggered outside of an action binding.

onRenderError — unified error handler

Receives (error, name) for three distinct error classes:

  • Component render errors — when a ComponentFn throws synchronously during renderSpec. name is the catalog component name (e.g. "Home").
  • Action handler rejections (emit path) — when an ActionFn throws or returns a rejected promise during emit(). name is the catalog action name (e.g. "submitForm").
  • Action handler rejections (watch path) — when an ActionFn rejects during a watch binding callback. name is the catalog action name.
const mount = createPlayUI(registryResult, {
onRenderError: (err, name) => {
// Route to your application's error tracking
Sentry.captureException(err, { extra: { name } });
},
});

The argument order matches the upstream RenderErrorHandler type from @json-render/core: error first, name second. This is consistent with the framework renderers (@json-render/solid, @json-render/react).

Without onRenderError, all three error types are logged via console.error and swallowed. No exception propagates, and no unhandled promise rejection is created.

The handler is also available to component implementations via ctx.ctx.onRenderError, enabling components to route their own internal errors through the same channel:

const MyComponent: ComponentFn<typeof catalog, "MyComponent"> = ({ ctx }) => {
try {
const el = doSomethingRisky();
return el;
} catch (err) {
ctx.ctx.onRenderError?.(err, "MyComponent");
return null;
}
};

API Summary

XMachines Layer

ExportKindDescription
createRenderer(catalog, components)functionOne-call factory — returns mount(actor, container, options?) → disconnect
createPlayUI(registryResult, options?)functionBatteries-included factory — returns MountFn
PlayRendererclassClass-based renderer with connect() / disconnect() lifecycle
defineRegistry(catalog, options)functionBuild a catalog-typed DomRegistry with typed handlers
renderSpec(...)functionPure Spec → DOM renderer (low-level)
schemaconstThe @json-render/dom schema — pass to defineCatalog()

Key Types

TypeDescription
ComponentFn<C, K>Catalog-typed component function — returns HTMLElement | Text | null
ComponentContext<C, K>Context passed to each component: props, children, emit, on, bindings, ctx
ActionFn<C, K>Catalog-typed action function — receives (params, setState, state)
EventHandleHandle returned by on(eventName) — has emit(), shouldPreventDefault, bound
SetStateState updater: (prev => next) => void
DefineRegistryResultResult from defineRegistry — has registry, handlers, executeAction
PlayDomOptionsOptions for PlayRenderer — extends UIProviderOptions
CreatePlayUIOptionsOptions for createPlayUI — extends UIProviderOptions, adds fallback
MountOptionsPer-mount options for MountFn: store, loading
MountFnThe mount function returned by createPlayUI: (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
ComputedFunctionType for named compute functions used with the functions option

Rendering Behaviour

  • Initial render is synchronous — the container is populated before connect() returns.
  • Signal-driven re-renders are microtask-deferredwatchSignal schedules updates on the next microtask queue tick.
  • Null view clears the container. A fallback element can be shown on initial mount when the view is null; it is not re-appended if the view later transitions back to null after a non-null view.
  • Double connect() is safe — calling connect() on an already-connected renderer auto-disconnects first.
  • disconnect() clears the container and unsubscribes all signal and store watchers.

Testing

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

Tests live in test/ and use Vitest with a jsdom environment. Coverage thresholds: 80% lines, functions, branches, and statements.

@xmachines/play-dom — Vanilla DOM renderer for XMachines Play architecture.

Public API split into two layers:

XMachines layer (this package):

  • createRenderer() — one-call factory: returns mount(actor, container, options?) → disconnect
  • createPlayUI() — batteries-included factory with full options: returns MountFn
  • PlayRenderer — class-based renderer with connect() / disconnect() lifecycle
  • PlayDomOptions, CreatePlayUIOptions, MountFn, MountOptions

json-render layer (upstreamable to @json-render/dom):

  • defineRegistry — build a catalog-typed DomRegistry
  • renderSpec — pure Spec → DOM renderer (uses resolveElementProps from core)
  • ComponentFn — catalog-typed component function type
  • ComponentContext — catalog-typed render context (props, emit, on, children, bindings)
  • ComponentRegistry — catalog-typed registry input type
  • DomComponentRenderer — raw element-level renderer type
  • DomRegistry — raw registry type
  • DomRenderContext — raw render context
  • EventHandle — event handle returned by on()
  • SetState — state updater function passed to ActionFn
  • CatalogHasActions — conditional type: true when catalog declares actions
  • BaseComponentProps — base props type for catalog component definitions

Classes

Interfaces

Type Aliases

Variables

Functions

References

RenderErrorHandler

Re-exports RenderErrorHandler