@xmachines/play-react
API / @xmachines/play-react
React renderer for XMachines Play architecture with atom-driven rendering.
Installation
pnpm add @xmachines/play-reactPeer dependencies. Install them separately:
pnpm add react react-dom @xmachines/core @xmachines/play @xmachines/play-atom @xstate/store @xmachines/json-render-react@0.21.0-xm.6 @xmachines/json-render-core@0.21.0-xm.6 @xmachines/json-render-xstate@0.21.0-xm.6The json-render packages are exact peers: install the version that this release names, 0.21.0-xm.6.
Supported versions:
react/react-dom:^18.0.0 || ^19.0.0@xmachines/core,@xmachines/playand@xmachines/play-atom: the version of this release@xstate/store:^4.2.3@xmachines/json-render-*:0.21.0-xm.6
@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.
Usage
Standard usage — PlayUIProvider + PlayRenderer
The recommended pattern for actor-driven React rendering:
import { PlayUIProvider, PlayRenderer, defineRegistry, schema } from "@xmachines/play-react";import { definePlayer, compose, PlayerActor } from "@xmachines/play-xstate";import { withView } from "@xmachines/play-xstate/view";import { defineCatalog } from "@xmachines/json-render-core";import type { AnyStateMachine } from "xstate";import { z } from "zod";
declare const myMachine: AnyStateMachine; // your xstate machine (states carry meta.view specs)
// 1. Define the catalog: the components and the actions that a view can nameconst myCatalog = defineCatalog(schema, { components: { Login: { props: z.object({ title: z.string() }) }, Dashboard: { props: z.object({ username: z.string() }) }, }, actions: { login: { params: z.object({ username: z.string() }) }, logout: {}, },});
// 2. Create and start the actor — a renderer needs the view capabilityconst actor = definePlayer({ machine: myMachine, actor: compose(PlayerActor, withView) })();actor.start();
// 3. Define the component registry with action handlersconst registryResult = defineRegistry(myCatalog, { components: { Login: ({ props }) => <h1>{props.title}</h1>, Dashboard: ({ props }) => <p>Welcome, {props.username}</p>, }, actions: { login: async (params) => { if (params) actor.send({ type: "auth.login", username: params.username }); }, logout: async () => actor.send({ type: "auth.logout" }), },});
// 4. Render — atoms drive view transitions automaticallyfunction App() { return ( <PlayUIProvider actor={actor} registryResult={registryResult}> <PlayRenderer /> </PlayUIProvider> );}With optional JSONUIProvider props
Pass navigation and validation helpers through PlayUIProvider:
import { PlayUIProvider, PlayRenderer, type PlayUIProviderProps } from "@xmachines/play-react";
// actor, registryResult from the Quick Start abovedeclare const actor: PlayUIProviderProps["actor"];declare const registryResult: PlayUIProviderProps["registryResult"];declare const myRouter: { push(path: string): void }; // your router
<PlayUIProvider actor={actor} registryResult={registryResult} navigate={(path) => myRouter.push(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";import type { ActorProviderProps } 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 abovedeclare const actor: ActorProviderProps["actor"];declare const registryResult: ActorProviderProps["registryResult"];
<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 atoms directly
import { useState } from "react";import { useAtomEffect, type PlayUIProviderProps } from "@xmachines/play-react";
function MyComponent({ actor }: { actor: PlayUIProviderProps["actor"] }) { const [view, setView] = useState(() => actor.currentView.get());
// The first argument names every atom that runs the effect. useAtomEffect( [actor.currentView], () => { 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 atom 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 |
|---|---|
useAtomEffect(sources, callback, deps?) | Subscribes to the atoms of sources. It runs the callback again when one of them changes, and the callback triggers the re-render with its own setState. A read inside the callback tracks nothing, so name every dependency in sources. 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> |
PlayErrorBoundaryProps | Props for <PlayErrorBoundary> |
PlayErrorBoundaryState | State shape for <PlayErrorBoundary> |
AnyPlayActor | Type alias for PlayActor — the bare actor type that the context providers use |
ViewContextValue | The value shape that usePlayView() returns |
DefineRegistryResult | The result of defineRegistry, for a registry that you declare or pass on |
OnRenderError | The (error, name, info) handler of onRenderError |
RenderErrorInfo | The info of a report: kind, key, type, and action |
RenderErrorKind | The kind of a failure, such as "element", "action", or "root" |
RenderErrorHandler | The older (error, name) handler type. It stays for compatibility |
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 atoms (@xmachines/play-atom) are the source of truth. PlayUIProvider observes the actor atoms with useAtomEffect, and it renders again when the current view changes. It groups rapid atom updates into microtasks, so React does not render more often than necessary.
License
MIT — see LICENSE.
@xmachines/play-react - React renderer for the XMachines Play architecture
This package is the React rendering layer, and it works through providers. It observes the actor atoms and renders the UI components with @xmachines/json-render-react. The architecture can therefore change the framework, because React is only a render target that subscribes to the atom changes.
Key principle: React state is NEVER the place for the business logic. It only triggers the render cycle of React. The atoms are the source of truth.
Standard use:
<PlayUIProvider actor={actor} registryResult={registryResult}> <PlayRenderer /></PlayUIProvider>The escape hatch (a custom composition): use <ActorProvider> directly, and give
the ViewContext values (registry, handlers, store from usePlayView()) to
<JSONUIProvider> through an inner bridge component. If you give only registry, you
lose the action handlers and you make a new store. PlayUIProvider is this exact
composition. Read its source for the reference bridge.
Classes
Interfaces
- ActionProviderProps
- ActorProviderProps
- ComponentContext
- DefineRegistryResult
- JSONUIProviderProps
- PlayErrorBoundaryProps
- PlayErrorBoundaryState
- PlayUIProviderProps
- RendererProps
- RenderErrorInfo
- StateProviderProps
- ValidationProviderProps
- ViewContextValue
- VisibilityProviderProps