@xmachines/play-react
v2.1.1
Published
React renderer for XMachines Play architecture with signal-driven rendering
Maintainers
Readme
@xmachines/play-react
React renderer for XMachines Play architecture with signal-driven rendering.
Installation
pnpm add @xmachines/play-reactPeer dependencies. Install them separately:
pnpm add react react-dom xstate @xstate/store @xmachines/json-render-react @xmachines/json-render-core @xmachines/json-render-xstateSupported versions:
react/react-dom:^18.0.0 || ^19.0.0xstate:^5.31.0@xstate/store:^3.17.0@xmachines/json-render-*:^0.20.0-xm.2
Usage
Standard usage — PlayUIProvider + PlayRenderer
The recommended pattern for actor-driven React rendering:
import { PlayUIProvider, PlayRenderer, defineRegistry } from "@xmachines/play-react";
import { definePlayer } from "@xmachines/play-xstate";
import { myMachine } from "./machine.js"; // your xstate machine (states carry meta.view specs)
import { myCatalog } from "./catalog.js"; // defineCatalog(schema, ...) result, using the schema from "@xmachines/json-render-react/schema"
import { Login, Dashboard } from "./components.js"; // your React components
// 1. Create and start the actor
const actor = definePlayer({ machine: myMachine })();
actor.start();
// 2. Define the component registry with action handlers
const registryResult = defineRegistry(myCatalog, {
components: { Login, Dashboard },
actions: {
login: async ({ username }) => actor.send({ type: "auth.login", username }),
logout: async () => actor.send({ type: "auth.logout" }),
},
});
// 3. Render — signals drive view transitions automatically
function App() {
return (
<PlayUIProvider actor={actor} registryResult={registryResult}>
<PlayRenderer />
</PlayUIProvider>
);
}With optional JSONUIProvider props
Pass navigation and validation helpers through PlayUIProvider:
// actor, registryResult from the Quick Start above
<PlayUIProvider
actor={actor}
registryResult={registryResult}
navigate={(path) => history.pushState(null, "", path)}
validationFunctions={{ isEmail: (v) => /^.+@.+$/.test(String(v)) }}
>
<PlayRenderer />
</PlayUIProvider>Custom provider composition
Use ActorProvider directly when you need to compose providers manually:
import type { ReactNode } from "react";
import { ActorProvider, JSONUIProvider, PlayRenderer, usePlayView } from "@xmachines/play-react";
// Handlers and store live in ViewContext, so an inner bridge component must
// read them via usePlayView() and forward all three to JSONUIProvider —
// passing only `registry` would drop the action handlers and create a fresh store.
function Bridge({ children }: { children: ReactNode }) {
const view = usePlayView();
return (
<JSONUIProvider registry={view.registry} handlers={view.handlers} store={view.store}>
{children}
</JSONUIProvider>
);
}
// actor, registryResult from the Quick Start above
<ActorProvider actor={actor} registryResult={registryResult}>
<Bridge>
<PlayRenderer />
</Bridge>
</ActorProvider>;Accessing the actor from inside the tree
import { useActor } from "@xmachines/play-react";
function SubmitButton() {
const actor = useActor();
return <button onClick={() => actor.send({ type: "SUBMIT" })}>Submit</button>;
}Subscribing to signals directly
import { useState } from "react";
import { useSignalEffect } from "@xmachines/play-react";
function MyComponent({ actor }) {
const [view, setView] = useState(null);
useSignalEffect(() => {
setView(actor.currentView.get());
}, [actor]); // deps: re-subscribe when the actor prop swaps
return <div>{view?.root}</div>;
}API Summary
Components
| Export | Description |
| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| <PlayUIProvider> | Composite provider. It wraps ActorProvider and JSONUIProvider. Use it as the standard entry point. |
| <PlayRenderer> | Zero-prop leaf component. Reads the current actor view from context and renders it. Must be inside PlayUIProvider or ActorProvider. |
| <ActorProvider> | The low-level provider. It owns the actor bridge, the signal subscription, and the StateStore lifecycle of each view. |
| <PlayErrorBoundary> | The React class error boundary that catches a render error of a catalog component. |
Hooks
| Export | Description |
| ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| useSignalEffect(callback, deps?) | Subscribes to the TC39 signal changes. It runs the callback again when a signal that the callback reads changes, and the callback triggers the re-render with its own setState. The optional deps array creates the subscription again, like useEffect. The hook removes the subscription on unmount. |
| useActor() | Returns the raw actor instance. Must be called inside an ActorProvider/PlayUIProvider tree. |
| usePlayView() | Returns { spec, handlers, registry, store } for the current view. Must be called inside an ActorProvider/PlayUIProvider tree. |
Types
| Export | Description |
| ------------------------ | -------------------------------------------------------------------------------------------------- |
| PlayUIProviderProps | Props for <PlayUIProvider> |
| ActorProviderProps | Props for <ActorProvider> (also exported as PlayRendererProps for migration compatibility) |
| PlayErrorBoundaryProps | Props for <PlayErrorBoundary> |
| PlayErrorBoundaryState | State shape for <PlayErrorBoundary> |
| AnyPlayActor | Type alias for AbstractActor<AnyActorLogic> — the bare actor type that the context providers use |
| ViewContextValue | The value shape that usePlayView() returns |
| RenderErrorHandler | Error handler callback type for render errors |
Re-exports from @xmachines/json-render-react
@xmachines/play-react re-exports the complete @xmachines/json-render-react surface, so a consumer needs one import only:
import {
defineRegistry,
useBoundProp,
JSONUIProvider,
StateProvider,
ActionProvider,
VisibilityProvider,
ValidationProvider,
Renderer,
} from "@xmachines/play-react";Key Principle
React state is never the place for the business logic. It only triggers the render cycle of React. The signals (@xmachines/play-signals) are the source of truth. PlayUIProvider observes the actor signals with useSignalEffect, and it renders again when the current view changes. It groups rapid signal updates into microtasks, so React does not render more often than necessary.
Testing
Run unit tests (jsdom environment):
pnpm --filter @xmachines/play-react testRun tests with coverage:
pnpm --filter @xmachines/play-react run test:coverageRun the browser integration tests. They require Chromium:
pnpm --filter @xmachines/play-react run test:browserCoverage thresholds: 80% lines, functions, branches, and statements.
License
MIT — see LICENSE.
