@xmachines/play-react
Documentation / @xmachines/play-react
React renderer consuming signals and UI schema with provider pattern
Signal-driven React rendering layer observing actor state with zero React state for business logic.
Overview
@xmachines/play-react provides PlayRenderer and useSignalEffect for building React UIs that passively observe actor signals. This package enables framework-swappable architecture where React is just a rendering target subscribing to signal changes — business logic lives entirely in the actor.
Per RFC Play v1, this package implements:
- Signal-Only Reactivity (INV-05): No useState/useReducer for business logic, signals only
- Passive Infrastructure (INV-04): Components observe signals, send events to actor
Key Principle: React state is never used for business logic. Signals are the source of truth.
Renderer receives actor via props (provider pattern), not children.
Installation
npm install react@^18.0.0 react-dom@^18.0.0npm install @xmachines/play-reactCurrent Exports
PlayRendereruseSignalEffectPlayErrorBoundaryPlayRendererProps(type)PlayErrorBoundaryProps(type)
Peer dependencies:
react^18.0.0 || ^19.0.0 — React runtimereact-dom^18.0.0 || ^19.0.0 — React DOM renderer
Quick Start
import { createRoot } from "react-dom/client";import { definePlayer } from "@xmachines/play-xstate";import { defineCatalog } from "@xmachines/play-catalog";import { PlayRenderer } from "@xmachines/play-react";import { z } from "zod";
// 1. Define catalog (business logic layer)const catalog = defineCatalog({ LoginForm: z.object({ error: z.string().optional() }), Dashboard: z.object({ userId: z.string(), username: z.string(), }),});
// 2. Create React components (view layer)const components = { LoginForm: ({ error, send }) => ( <form onSubmit={(e) => { e.preventDefault(); const data = new FormData(e.currentTarget); send({ type: "auth.login", username: data.get("username"), }); }} > {error && <p style={{ color: "red" }}>{error}</p>} <input name="username" required placeholder="Username" /> <button type="submit">Log In</button> </form> ), Dashboard: ({ userId, username, send }) => ( <div> <h1>Welcome, {username}!</h1> <p>User ID: {userId}</p> <button onClick={() => send({ type: "auth.logout" })}>Log Out</button> </div> ),};
// 3. Create player actor (business logic runtime)const createPlayer = definePlayer({ machine: authMachine, catalog });const actor = createPlayer();actor.start();
// 4. Render UI (actor via props)const root = createRoot(document.getElementById("app")!);root.render(<PlayRenderer actor={actor} components={components} />);API Reference
PlayRenderer
Main renderer component subscribing to actor signals and dynamically rendering catalog components:
interface PlayRendererProps { actor: AbstractActor<AnyActorLogic> & Viewable; components: Record<string, React.ElementType>; fallback?: React.ReactNode;}Props:
actor- Actor instance withcurrentViewsignalcomponents- Map of component names to React componentsfallback- Component shown whencurrentViewis null (default:null)
Behavior:
- Subscribes to
actor.currentViewsignal viauseSignalEffect - Looks up component from
componentsmap usingview.componentstring - Renders component with props from
view.props+sendfunction
Example:
<PlayRenderer actor={actor} components={{ HomePage: ({ send }) => <div>Home</div>, AboutPage: ({ send }) => <div>About</div>, }} fallback={<div>Loading...</div>}/>useSignalEffect()
Hook for subscribing to signal changes with automatic cleanup:
useSignalEffect(() => { const value = signal.get(); // React re-renders when signal changes});Behavior:
- Tracks signal dependencies automatically via
Signal.Computedwrapper - Uses
Signal.subtle.Watcherwith microtask batching - Triggers React state update to force re-render
- Cleans up watcher on unmount with explicit
unwatch
Canonical watcher lifecycle:
notifyqueueMicrotask- drain pending work (
getPendingand/orComputed.get) - run effect + trigger render
- re-arm watcher via
watch()
Watcher notification is one-shot, so re-arm and explicit cleanup are both required.
Example:
import { useSignalEffect } from "@xmachines/play-react";import { useState } from "react";
function RouteDisplay({ actor }: { actor: AbstractActor<any> }) { const [route, setRoute] = useState<string | null>(null);
useSignalEffect(() => { const currentRoute = actor.currentRoute.get(); setRoute(currentRoute); });
return <div>Current Route: {route ?? "None"}</div>;}PlayErrorBoundary
React class component error boundary for catching catalog component render errors.
PlayRenderer wraps its render output in PlayErrorBoundary automatically. You can also use it directly to wrap any component that may throw during render.
interface PlayErrorBoundaryProps { fallback?: React.ReactNode; // UI shown when a child throws (default: null) children: React.ReactNode; onError?: (error: Error, info: React.ErrorInfo) => void; // Forward to Sentry, Datadog, etc.}Props:
fallback— ReactNode rendered when a child component throws. Defaults tonull.onError— Optional callback forwarded on every caught error. Use for production observability (Sentry, Datadog, custom logging).
Example:
import { PlayErrorBoundary } from "@xmachines/play-react";
<PlayErrorBoundary fallback={<div className="error">Something went wrong.</div>} onError={(error) => Sentry.captureException(error)}> <YourCatalogComponent /></PlayErrorBoundary>;Works with React 18 and React 19. Uses the standard class component componentDidCatch + getDerivedStateFromError pattern.
Examples
Component Receiving Props from Catalog
import { PlayRenderer } from "@xmachines/play-react";import { defineCatalog } from "@xmachines/play-catalog";import { z } from "zod";
// Define schema in catalogconst catalog = defineCatalog({ UserProfile: z.object({ userId: z.string(), name: z.string(), avatar: z.string().url().optional(), stats: z.object({ posts: z.number(), followers: z.number(), }), }),});
// Component receives type-safe props + sendconst components = { UserProfile: ({ userId, name, avatar, stats, send }) => ( <div> {avatar && <img src={avatar} alt={name} />} <h1>{name}</h1> <p>ID: {userId}</p> <div> <span>{stats.posts} posts</span> <span>{stats.followers} followers</span> </div> <button onClick={() => send({ type: "profile.edit", userId, }) } > Edit Profile </button> </div> ),};
<PlayRenderer actor={actor} components={components} />;useSignalEffect for Custom Rendering
import { useSignalEffect } from "@xmachines/play-react";import { AbstractActor } from "@xmachines/play-actor";
function CustomRenderer({ actor }: { actor: AbstractActor<any> }) { const [view, setView] = useState(null);
// Subscribe to currentView signal useSignalEffect(() => { const currentView = actor.currentView.get(); setView(currentView); });
if (!view) return <div>No view</div>;
// Custom rendering logic if (view.component === "SpecialCase") { return <SpecialCaseComponent {...view.props} actor={actor} />; }
// Fallback to standard rendering return <DefaultComponent view={view} actor={actor} />;}Provider Pattern
import { PlayTanStackRouterProvider } from "@xmachines/play-tanstack-react-router";import { PlayRenderer } from "@xmachines/play-react";
// Renderer receives actor via props (not children)function App() { return ( <PlayTanStackRouterProvider actor={actor} router={router} renderer={(currentActor, currentRouter) => { void currentRouter; return ( <div> <Header actor={currentActor} /> <PlayRenderer actor={currentActor} components={components} /> <Footer /> </div> ); }} /> );}
// Header component also receives actorfunction Header({ actor }: { actor: AbstractActor<any> }) { const [route, setRoute] = useState<string | null>(null);
useSignalEffect(() => { setRoute(actor.currentRoute.get()); });
return ( <header> <nav>Current: {route}</nav> </header> );}Architecture
This package implements Signal-Only Reactivity (INV-05) and Passive Infrastructure (INV-04):
-
No Business Logic in React:
- No useState/useReducer for business state
- No useEffect for side effects
- React only triggers renders, doesn’t control state
-
Signals as Source of Truth:
actor.currentView.get()provides UI structureactor.currentRoute.get()provides navigation state- Components observe signals via
useSignalEffect
-
Event Forwarding:
- Components receive
sendfunction via props - User actions send events to actor (e.g.,
{ type: "auth.login" }) - Actor guards validate and process events
- Components receive
-
Microtask Batching:
Signal.subtle.Watchercoalesces rapid signal changes- Prevents React thrashing from multiple signal updates
- Single React render per microtask batch
-
Explicit Disposal Contract:
- Component teardown must call watcher
unwatchin cleanup - Do not rely on GC-only cleanup
- Component teardown must call watcher
Pattern:
- Renderer receives actor via props (provider pattern)
- Enables composition with navigation, headers, footers
- Supports multiple renderers in same app
Architectural Invariants:
- Signal-Only Reactivity (INV-05): No React state for business logic
- Passive Infrastructure (INV-04): Components reflect, never decide
Benefits
- Framework Swappable: Business logic has zero React imports
- Type Safety: Props validated against catalog schemas
- Simple Testing: Test actors without React renderer
- Performance: Microtask batching reduces unnecessary renders
- Composability: Renderer prop enables complex layouts ()
Related Packages
- @xmachines/play-xstate - XState adapter providing actors
- @xmachines/play-catalog - UI schema validation
- @xmachines/play-tanstack-react-router - TanStack Router integration
- @xmachines/play-actor - Actor base
- @xmachines/play-signals - TC39 Signals primitives
License
Copyright (c) 2016 Mikael Karon. All rights reserved.
This work is licensed under the terms of the MIT license.
For a copy, see https://opensource.org/licenses/MIT.
@xmachines/play-react - React renderer for XMachines Play architecture
Provides a thin React rendering layer that passively observes actor signals and renders UI components from catalog definitions. This package enables framework-swappable architecture where React is just a rendering target that subscribes to signal changes.
Key principle: React state is NEVER used for business logic—only for triggering React’s render cycle. Signals are the source of truth.