Skip to content

@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

Terminal window
npm install react@^18.0.0 react-dom@^18.0.0
npm install @xmachines/play-react

Current Exports

  • PlayRenderer
  • useSignalEffect
  • PlayErrorBoundary
  • PlayRendererProps (type)
  • PlayErrorBoundaryProps (type)

Peer dependencies:

  • react ^18.0.0 || ^19.0.0 — React runtime
  • react-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 with currentView signal
  • components - Map of component names to React components
  • fallback - Component shown when currentView is null (default: null)

Behavior:

  1. Subscribes to actor.currentView signal via useSignalEffect
  2. Looks up component from components map using view.component string
  3. Renders component with props from view.props + send function

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.Computed wrapper
  • Uses Signal.subtle.Watcher with microtask batching
  • Triggers React state update to force re-render
  • Cleans up watcher on unmount with explicit unwatch

Canonical watcher lifecycle:

  1. notify
  2. queueMicrotask
  3. drain pending work (getPending and/or Computed.get)
  4. run effect + trigger render
  5. 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 to null.
  • 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 catalog
const 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 + send
const 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 actor
function 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):

  1. No Business Logic in React:

    • No useState/useReducer for business state
    • No useEffect for side effects
    • React only triggers renders, doesn’t control state
  2. Signals as Source of Truth:

    • actor.currentView.get() provides UI structure
    • actor.currentRoute.get() provides navigation state
    • Components observe signals via useSignalEffect
  3. Event Forwarding:

    • Components receive send function via props
    • User actions send events to actor (e.g., { type: "auth.login" })
    • Actor guards validate and process events
  4. Microtask Batching:

    • Signal.subtle.Watcher coalesces rapid signal changes
    • Prevents React thrashing from multiple signal updates
    • Single React render per microtask batch
  5. Explicit Disposal Contract:

    • Component teardown must call watcher unwatch in cleanup
    • Do not rely on GC-only cleanup

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 ()

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.

Classes

Interfaces

Variables

Functions