@xmachines/play-solid
Documentation / @xmachines/play-solid
SolidJS renderer consuming signals and UI schema with provider pattern
Signal-driven SolidJS rendering layer observing actor state with zero SolidJS state for business logic.
Overview
@xmachines/play-solid provides PlayRenderer for building SolidJS UIs that passively observe actor signals. This package enables framework-swappable architecture where SolidJS 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 createSignal/createStore for business logic, TC39 signals only
- Passive Infrastructure (INV-04): Components observe signals, send events to actor
Key Principle: SolidJS 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 solid-js@^1.8.0npm install @xmachines/play-solidCurrent Exports
PlayRendererPlayRendererProps(type)
Peer dependencies:
solid-js^1.8.0 - SolidJS runtime
Quick Start
import { render } from "solid-js/web";import { definePlayer } from "@xmachines/play-xstate";import { defineCatalog } from "@xmachines/play-catalog";import { PlayRenderer } from "@xmachines/play-solid";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 SolidJS components (view layer)const components = { LoginForm: (props) => ( <form onSubmit={(e) => { e.preventDefault(); const data = new FormData(e.currentTarget); props.send({ type: "auth.login", username: data.get("username"), }); }} > {props.error && <p style={{ color: "red" }}>{props.error}</p>} <input name="username" required placeholder="Username" /> <button type="submit">Log In</button> </form> ), Dashboard: (props) => ( <div> <h1>Welcome, {props.username}!</h1> <p>User ID: {props.userId}</p> <button onClick={() => props.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)render( () => <PlayRenderer actor={actor} components={components} />, document.getElementById("app")!,);API Reference
PlayRenderer
Main renderer component subscribing to actor signals and dynamically rendering catalog components:
interface PlayRendererProps { actor: AbstractActor<any>; components: Record<string, Component<any>>; fallback?: JSX.Element;}Props:
actor- Actor instance withcurrentViewsignalcomponents- Map of component names to SolidJS componentsfallback- Component shown whencurrentViewis null
Behavior:
- Subscribes to
actor.currentViewsignal using aSignal.subtle.Watcherinside the component - Looks up component from
componentsmap usingview.componentstring - Renders component with props from
view.props+sendfunction using Solid’s<Dynamic />
Example:
<PlayRenderer actor={actor} components={{ HomePage: (props) => <div>Home</div>, AboutPage: (props) => <div>About</div>, }} fallback={<div>Loading...</div>}/>Examples
Component Receiving Props from Catalog
import { PlayRenderer } from "@xmachines/play-solid";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: (props) => ( <div> {props.avatar && <img src={props.avatar} alt={props.name} />} <h1>{props.name}</h1> <p>ID: {props.userId}</p> <div> <span>{props.stats.posts} posts</span> <span>{props.stats.followers} followers</span> </div> <button onClick={() => props.send({ type: "profile.edit", userId: props.userId, }) } > Edit Profile </button> </div> ),};
<PlayRenderer actor={actor} components={components} />;Provider Pattern
import { PlayTanStackRouterProvider } from "@xmachines/play-tanstack-solid-router";import { PlayRenderer } from "@xmachines/play-solid";import { createSignal, onCleanup } from "solid-js";
// Renderer receives actor via props (not children)function App() { return ( <PlayTanStackRouterProvider actor={actor} router={router} routeMap={routeMap} renderer={(currentActor, currentRouter) => { return ( <div> <Header actor={currentActor} /> <PlayRenderer actor={currentActor} components={components} /> <Footer /> </div> ); }} /> );}
// Header component also receives actorfunction Header(props) { const [route, setRoute] = createSignal<string | null>(null);
// Manual watcher setup for custom component reading signals let watcher: Signal.subtle.Watcher;
// ... setup watcher to update route signal ...
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 SolidJS:
- No createSignal/createStore for business state
- No createEffect for business side effects
- SolidJS 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 explicit watcher patterns
-
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 SolidJS thrashing from multiple signal updates
- Single SolidJS render per microtask batch
-
Explicit Disposal Contract:
- Component teardown calls watcher
unwatchinonCleanup - Do not rely on GC-only cleanup
- Component teardown calls 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 SolidJS state for business logic
- Passive Infrastructure (INV-04): Components reflect, never decide
Canonical Watcher Lifecycle
If you write your own custom integration, use the same watcher flow as PlayRenderer:
notifycallback runs- Schedule work with
queueMicrotask - Drain
watcher.getPending() - Read actor signals and update SolidJS-local signal state (
setSignal()) - Re-arm with
watch(...)orwatch()
Watcher notify is one-shot. Re-arm is required for continuous observation.
Benefits
- Framework Swappable: Business logic has zero SolidJS imports
- Type Safety: Props validated against catalog schemas
- Simple Testing: Test actors without SolidJS 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-solid-router - Solid Router integration
- @xmachines/play-tanstack-solid-router - TanStack Solid 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.
SolidJS renderer for XMachines Play architecture