Skip to content

@xmachines/play-dom

API / @xmachines/play-dom

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

License: MIT Version

Installation

Terminal window
pnpm add @xmachines/play-dom

Peer dependencies:

Terminal window
pnpm add @xmachines/core @xmachines/play @xmachines/play-atom @xstate/store @xmachines/json-render-core@0.21.0-xm.6 @xmachines/json-render-dom@0.21.0-xm.6 @xmachines/json-render-xstate@0.21.0-xm.6

The json-render packages are exact peers: install the version that this release names, 0.21.0-xm.6.

@xmachines/play is the peer of @xmachines/play-view, which this package depends on. Install ONE copy of it.

@xstate/store is the peer of @xmachines/play-atom, and not of this package: this package reads the atom through @xmachines/play-atom and imports the engine in no source file. Install ONE copy of it.

Quick Start

import { createRenderer, schema } from "@xmachines/play-dom";
import { definePlayer, compose, PlayerActor } from "@xmachines/play-xstate";
import { withView } from "@xmachines/play-xstate/view";
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: [] } },
},
},
},
},
});
// `mount` needs the view capability
const actor = definePlayer({ machine, actor: compose(PlayerActor, withView) })();
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, directives, validationFunctions, navigate, onConfirm, confirmDialog, onRenderError, onError, 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, type DefineRegistryResult } from "@xmachines/play-dom";
import type { ViewActor } from "@xmachines/play-view";
declare const actor: ViewActor; // the actor of the Quick Start
// The result of defineRegistry(catalog, { components, actions }), as in the createPlayUI example
declare const registryResult: DefineRegistryResult;
const container = 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. The tree lives as long as the store, so a new viewKey updates the tree in place, as the framework renderers do:

import { PlayRenderer, type DefineRegistryResult } from "@xmachines/play-dom";
import { createAtom } from "@xmachines/play-atom";
import { xstateStoreStateStore } from "@xmachines/json-render-xstate";
import type { ViewActor } from "@xmachines/play-view";
declare const actor: ViewActor; // the actor of the Quick Start
declare const registryResult: DefineRegistryResult; // from the previous example
const atom = createAtom<Record<string, unknown>>({ username: "" });
const store = xstateStoreStateStore({ atom });
const container = document.getElementById("app")!;
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.

import { createPlayUI, type DefineRegistryResult } from "@xmachines/play-dom";
declare const registryResult: DefineRegistryResult; // from defineRegistry(catalog, ...)
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, and nothing throws.

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 { createPlayUI, schema } from "@xmachines/play-dom";
import type { ComponentFn, DefineRegistryResult } from "@xmachines/play-dom";
import { defineCatalog, runValidationCheck } from "@xmachines/json-render-core";
import { z } from "zod";
declare const registryResult: DefineRegistryResult; // from defineRegistry(catalog, ...)
const mount = createPlayUI(registryResult, {
validationFunctions: {
isEven: (value) => typeof value === "number" && value % 2 === 0,
phoneNumber: (value) => /^\+?[\d\s\-()]{7,}$/.test(String(value)),
},
});
const catalog = defineCatalog(schema, {
components: { Login: { props: z.object({ title: z.string() }) } },
actions: {},
});
// Inside a ComponentFn:
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:

import { createPlayUI, type DefineRegistryResult } from "@xmachines/play-dom";
declare const registryResult: DefineRegistryResult; // from defineRegistry(catalog, ...)
// React Router / TanStack Router / any push-based router:
declare const myRouter: { push(path: string): void };
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.

onConfirm and confirmDialog — the bindings with confirm

An action binding with a confirm field waits for the user. Without onConfirm, the default confirm dialog of @xmachines/json-render-dom shows the binding on document.body, as the ConfirmDialog of the framework renderers does. The action runs when the user confirms, and a cancel runs nothing and throws nothing. disconnect() and a new view cancel a confirmation that waits.

  • Give onConfirm to decide each binding in your own code. It receives the confirm config and the resolved action, and it returns true, or a promise of true, to run the action.
  • Give confirmDialog: false to turn the default dialog off. The binding then waits for a component of your catalog, which reads and answers it through ctx.ctx.confirmation.
import { createPlayUI, type DefineRegistryResult } from "@xmachines/play-dom";
declare const registryResult: DefineRegistryResult; // from defineRegistry(catalog, ...)
const mount = createPlayUI(registryResult, {
onConfirm: (confirm) => window.confirm(`${confirm.title}\n${confirm.message}`),
});

onRenderError — unified error handler

The inner renderer calls the handler with (error, name, info) one time for each failure that it reports. info.kind names the failure:

  • "element" — a ComponentFn below the root throws, or an effect of it throws. name is the catalog component name, for example "Home". The element shows nothing until a new version of it arrives.
  • "props" and "visible" — a prop expression or the visible condition of a child throws. The boundary of the parent shows nothing, and name is the type of the parent.
  • "action" and "watch" — an on chain or a watch chain fails, for example an ActionFn that throws or rejects. name is the catalog action name, for example "submitForm". The chain stops, and the element stays.
  • "root" — the component, a prop, or the visible condition of the root throws. No view stays on the screen, so PlayRenderer also contains the failure as a failure of the view: see onError.

info.key and info.type name the element, and info.action names the action of a chain. A handler with the two parameters (error, name) stays valid.

import { createPlayUI, type DefineRegistryResult } from "@xmachines/play-dom";
declare const registryResult: DefineRegistryResult; // from defineRegistry(catalog, ...)
// Sentry: your error tracking client
declare const Sentry: { captureException(error: unknown, context?: object): void };
const mount = createPlayUI(registryResult, {
onRenderError: (err, name, info) => {
// Route to your application's error tracking
Sentry.captureException(err, { extra: { name, kind: info.kind, key: info.key } });
},
});

The type is OnRenderError of @xmachines/json-render-core, and this package exports it with RenderErrorInfo and RenderErrorKind. The framework renderers use the same handler, and they contain a failure of the root in the same way.

Without onRenderError, the renderer reads the handler that defineRegistry(catalog, { onRenderError }) put on the registry. Without that handler too, it writes one console.error line for each failure, such as [json-render] element error in <Home> "home":, and then the error. No exception goes to the caller, and no promise rejection stays unhandled. A failure of the root gets no such line: the containment reports it to onError, or to its own console line. A handler that throws gives its error to reportError of the page one time.

The renderer contains a failed rebuild

Read this if you catch what mount() or connect() throws.

The renderer contains a view that fails the rebuild ALWAYS. No option turns the containment on, and no option turns it off. The renderer clears the container, it shows the fallback, and mount() returns normally. A host that shows its own error page from a catch around mount() never sees that catch run.

The four framework renderers contain a failed render always, because an error boundary of a framework is not an option that a caller turns off. play-dom follows the same rule.

To escalate a failure, raise it from a task of your own. The example imports rethrowLater from @xmachines/core/utils. Install that package next to this one, because your code imports it and a package manager such as pnpm does not let your code import a package that it does not declare:

import { rethrowLater } from "@xmachines/core/utils";
import { createPlayUI, type DefineRegistryResult } from "@xmachines/play-dom";
declare const registryResult: DefineRegistryResult; // from defineRegistry(catalog, ...)
declare function reportToSentry(error: unknown): void; // your error tracking
const mount = createPlayUI(registryResult, {
onError: (err) => {
reportToSentry(err);
rethrowLater(err); // the page keeps its own global handler
},
});

A throw that leaves the handler itself reaches no caller. The renderer contains it and writes it to console.error, exactly as the four framework renderers do. The five hold this one rule, so a host learns it one time and writes the same handler for each of them.

onError: the failure of a complete rebuild

onRenderError covers one element: the inner renderer contains a render error of a component below the root, and a rejection of an action handler, per element, and the rest of the view stays. A failure of the ROOT leaves no view: its component throws, or a $computed function throws during the resolution of a prop of the root, for example. The resolution of the store of the view can fail too, when an accessor of spec.state throws.

onError receives such a failure, one time. A failure of the root reaches onRenderError too, with info.kind "root", so a host that sends both handlers to one tool skips that kind in one of them. The renderer contains the failure with this option and without it: it resets the state of the failed rebuild, so that the next emission makes a complete render again, and it clears the container. connect() renders the first view synchronously, and a bad initial view does not make connect() throw.

A write of the store takes the same path. The renderer shows a write at its commit point, a microtask after the write, and it resolves the props of every element that the write touched there. A $computed function of the root that throws on the new state fails the root. The renderer contains such a failure as well, and the action handler that wrote the state receives no exception.

The second parameter is the reset, for a retry that the host starts. It renders the view that the actor holds at the moment of the call, so a retry cannot rewind the screen to the view that failed. A reset that the host calls from inside the handler does nothing, because no input changed between the two attempts, and a reset after the provider goes away does nothing. The five renderers hold the same three rules.

A reset of PlayRenderer belongs to ONE connection. connect() starts a connection, and the reset of a report of an older connection does nothing. A host that calls connect() again from inside the handler abandons the connection that reported. The “Retry” button of that report must not render into the connection that took its place. Call the reset of the newest report.

A fourth rule holds in all five: each of them CONTAINS a handler that throws, and writes the throw to console.error. A host that must escalate raises the failure from a task of its own, as the section above shows.

import { createPlayUI, type DefineRegistryResult } from "@xmachines/play-dom";
declare const registryResult: DefineRegistryResult; // from defineRegistry(catalog, ...)
declare function reportToSentry(error: unknown): void; // your error tracking
const mount = createPlayUI(registryResult, {
onError: (err) => reportToSentry(err),
fallback: document.getElementById("crashed")!,
});

createPlayUI adds the parity with the framework providers: the renderer contains the failure, and it appends the fallback to the container that the failed rebuild left clear. A later view that renders clears the container again, which removes the fallback.

onError says WHERE the report goes, and fallback says what the empty container shows. Neither option turns the containment on. Without onError the renderer writes the contained error to console.error, in the same way as it does for a component that throws without an onRenderError handler. Give onError to send the failure to your own observability tool instead.

One fallback element belongs to one mount. appendChild moves a node. Two mounts of the same factory therefore take the element from each other on every null view, and the container of the first one goes empty without a notice. Build one element for each mount() call.

A fallback producer that throws is contained too — on a null view and after a failed rebuild alike — and it reports to console.error and NOT to onError. After a failed rebuild the renderer calls the producer from inside the containment of the view, so a second trip through onError would report the failure of your fallback as a failure of the view. The container stays empty, and the renderer renders the next view that works.

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

import { schema, type ComponentFn } from "@xmachines/play-dom";
import { defineCatalog } from "@xmachines/json-render-core";
import { z } from "zod";
const catalog = defineCatalog(schema, {
components: { Home: { props: z.object({ title: z.string() }) } },
actions: {},
});
declare function doSomethingRisky(): HTMLElement; // 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", { kind: "element", type: "Home" });
return null;
}
};

API Summary

XMachines Layer

ExportKindDescription
createRenderer(catalog, components)functionThe one-call factory. Its mount hands a plain release back
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, adds fallback and onError
CreatePlayUIOptionsOptions for createPlayUI — extends UIProviderOptions, adds fallback and onError
MountOptionsPer-mount options for MountFn: store, loading
MountFnThe mount: (actor, container, options?) → () => void. createPlayUI returns one, and a consumer may write one
UIProviderOptionsShared options: functions, directives, validationFunctions, navigate, onRenderError, onConfirm, confirmDialog
OnRenderErrorThe (error, name, info) handler of onRenderError. RenderErrorInfo and RenderErrorKind type its info
BaseComponentProps<P>Catalog-agnostic component props for shared component libraries
DomRegistryRaw registry type: Record<string, DomComponentRenderer>
DomSchemaType of the schema export

Rendering Behavior

  • The first render is synchronous — the renderer fills the container before connect() returns.
  • An atom-driven render waits for a microtask — watchAtom puts each update on the next tick of the microtask queue.
  • A null view clears the container, and the renderer then shows the fallback element when you give one. It shows it for every null view, and not for the first mount only — the four framework providers hold the same rule for their placeholder content.
  • A store write shows at its commit point — the inner renderer renders the writes of one task together, in a microtask after the first write. Code that reads the DOM right after a write waits for that microtask.
  • A second connect() is safe — a connect() call on a connected renderer disconnects it first.
  • disconnect() clears the container and cancels every atom watcher and store watcher. It also stops a store write that waits for its commit point, and it cancels a confirmation that waits.

@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