@playgenx/components
v0.2.2
Published
Default React 19 implementations for every entry in DEFAULT_REGISTRY. Pair with @playgenx/registry and the parser/validator pipeline.
Readme
@playgenx/components
Default React 19 implementations for every entry in DEFAULT_REGISTRY.
The components, a per-Provider reactive state store, error boundaries,
and a CLI for inspecting state snapshots.
npm install @playgenx/componentsReact 19 is a peer dependency — your app provides it.
// package.json (the bits you need)
{
"dependencies": {
"@playgenx/components": "^0.2.1",
"react": "^19.2.0",
"react-dom": "^19.2.0"
}
}Looking for the renderer (parser + tree walker)? That's a separate package —
@playgenx/renderer. This package is the component library; the renderer consumes it.
Quick start
import {
Slider,
PlaygroundStateProvider,
usePlaygroundState,
} from '@playgenx/components';
import { useState } from 'react';
// 1. Standalone, no state — works without a Provider
export function StaticVolume() {
return <Slider min={0} max={10} value={4} label="Volume" />;
}
// 2. With shared state — wrap your tree
export function VolumePanel() {
return (
<PlaygroundStateProvider initial={{ volume: 5 }}>
<Slider min={0} max={10} stateKey="volume" label="Volume" />
<VolumeReadout />
</PlaygroundStateProvider>
);
}
function VolumeReadout() {
const state = usePlaygroundState();
return <span>Current: {state.get<number>('volume')}</span>;
}What's in the box
Components (PascalCase, React 19)
Every component accepts JSON-friendly props. See index.ts for the
full surface.
| Component | Purpose | Notable props |
|---------------|--------------------------------------------|--------------------------------|
| Button | Headless button | label, variant, onClick |
| TextField | Controlled / uncontrolled input | value, onChange |
| Slider | Range input | min, max, value, stateKey |
| Chart | bar / line / pie SVG charts | kind, data, title |
| Container | Layout primitive | padding, gap |
| Code | Inline code / <pre><code> | language |
| Heading | <h1>–<h6> | level |
| Text | <p> with weight + color | weight, color |
| Stepper | Multi-step reveal | steps, initial |
| Card | Titled card | title, children |
| List | <ul> / <ol> from array | items, ordered |
State store + React glue
| Export | What it does |
|------------------------------|-----------------------------------------------------------|
| createPlaygroundState(initial?) | Create a standalone state store |
| PlaygroundStateProvider | Wrap your tree with a Provider |
| usePlaygroundState() | Read the nearest store |
| useBoundValue(key, fb) | Get a live [value, setter] tuple (re-renders on change) |
| useBoundValueOrUndefined(key, fb) | Same; returns undefined if no key/provider |
| useStoreSnapshot() | Non-subscribing {get, set, subscribe, snapshot, ...} |
| useBoundSelector(key, sel, fb) | Live bound value with selector transform; only re-renders on slice change |
| useMultiBoundValue(bindings) | Bind multiple keys; returns Record<key, [value, setter]> |
| useStateAction(action) | Build an onClick from {set, toggle} config |
Pure helpers (no React, no Provider)
| Export | Returns |
|-----------------------|------------------------------------|
| dumpState(state) | JSON v1 envelope (sorted keys) |
| diffState(a, b) | {added, changed, removed} or null |
| diffSnapshots(prev, next) | Same as diffState but pure |
| validateStateKey(k) | null if valid, error message if not |
| clearState(state) | (void) — sets every key to undefined |
| batch(state, fn) | Atomic set commit, per-key dedup |
Error handling
| Export | Purpose |
|------------------------------|------------------------------------|
| ArtifactErrorBoundary | Class-component error boundary |
| ShowSource | Render-prop source-toggle |
Registry
| Export | Purpose |
|-------------------------|--------------------------------------|
| componentMap | Frozen default registry (11 entries) |
| createRegistry(overrides) | Returns a new frozen map |
Three ways to use the state store
1. Standalone, outside React
import { createPlaygroundState } from '@playgenx/components';
const state = createPlaygroundState({ count: 0 });
state.set('count', 5);
state.get('count'); // 5
state.snapshot(); // { count: 5 }
state.subscribe('count', (v) => console.log('count is now', v));
state.set('count', 10); // → console.log fires on the next microtask2. React tree with a Provider
import { PlaygroundStateProvider, useBoundValue } from '@playgenx/components';
function VolumeControl() {
// Live-bound: writes to state on change, re-renders on external writes
const [volume, setVolume] = useBoundValue<number>('volume', 50);
return (
<input
type="range"
min={0}
max={100}
value={volume}
onChange={(e) => setVolume(Number(e.currentTarget.value))}
/>
);
}
export function App() {
return (
<PlaygroundStateProvider initial={{ volume: 50 }}>
<VolumeControl />
</PlaygroundStateProvider>
);
}3. State-bound component (the simple path)
// No need to write your own useBoundValue — Slider + stateKey does it.
<PlaygroundStateProvider initial={{ volume: 50 }}>
<Slider min={0} max={100} stateKey="volume" />
</PlaygroundStateProvider>Flexibility patterns — pick the right hook for the job
v0.2.2 ships five hooks for binding components to state. They differ in what triggers a re-render, what they assume about Providers, and how they expose state to your code. Pick the lowest-cost one that fits.
| Hook | Subscribes? | Needs Provider? | SSR-safe? | Use when… |
|------------------------|-------------|------------------|-----------|-----------------------------------------|
| useBoundValue | yes | yes (throws) | yes | You need to re-render on state changes and a Provider is mandatory |
| useBoundValueOrUndefined | yes | no (graceful) | yes | stateKey is optional; no Provider = no-op binding |
| useStoreSnapshot | no | yes (throws) | n/a | You read/write state in handlers but don't want re-renders |
| useBoundSelector | yes (filtered) | yes (throws) | yes | One key holds an object; you only care about one slice |
| useMultiBoundValue | yes (1 per key) | yes (throws) | yes | A form/dashboard binds 5-10 keys; you want a Record |
useStoreSnapshot — for non-subscribing reads
When you need state in event handlers but don't want the component to re-render on every change:
import { useStoreSnapshot } from '@playgenx/components';
function SaveButton() {
const { get, set, snapshot } = useStoreSnapshot();
return (
<button onClick={() => {
// Read multiple keys atomically, write back, no re-render
const form = snapshot();
if (form.dirty) {
set('savedAt', Date.now());
api.save(form);
}
}}>
Save
</button>
);
}The hook returns { state, get, set, subscribe, snapshot, replaceAll } —
all stable references for the Provider's lifetime.
useBoundSelector — slice-aware subscriptions
When one key holds an object and you only care about one field:
import { useBoundSelector } from '@playgenx/components';
interface User { name: string; age: number; email: string; }
function CurrentUserBadge() {
// Only re-renders when `name` changes, not when `age` changes.
const [name] = useBoundSelector<User, string>(
'currentUser',
(u) => u?.name ?? 'guest',
'guest',
);
return <span>Hi, {name}!</span>;
}Important: write selectors that return primitives or stable refs.
A selector that returns a fresh object (u => ({...u})) defeats the
slice-detection and re-renders on every store change.
useMultiBoundValue — bind many keys at once
For forms and dashboards where you have several bound inputs in one component:
import { useMultiBoundValue } from '@playgenx/components';
function SettingsForm() {
const form = useMultiBoundValue({
name: { fallback: '' },
email: { fallback: '' },
age: { fallback: 0 },
role: { fallback: 'user' },
});
return (
<>
<input value={form.name[0]} onChange={e => form.name[1](e.target.value)} />
<input value={form.email[0]} onChange={e => form.email[1](e.target.value)} />
<input type="number" value={form.age[0]} onChange={e => form.age[1](+e.target.value)} />
<select value={form.role[0]} onChange={e => form.role[1](e.target.value)}>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</>
);
}The hook subscribes once per key (so 5 keys = 5 subscriptions). For
50+ rows of dynamic data, use useStoreSnapshot + manual get/set
calls instead — the hook count is bounded.
SSR — state is reflected in the first render
All useBoundValue* hooks use React 19's useSyncExternalStore so
the server-rendered HTML matches the live store value, not a
fallback. renderToString(<Slider stateKey="v" />) inside a Provider
with initial={{ v: 7 }} produces <input value="7" ... /> — no
hydration mismatch.
// Server
import { renderToString } from 'react-dom/server';
import { Slider, PlaygroundStateProvider } from '@playgenx/components';
const html = renderToString(
<PlaygroundStateProvider initial={{ volume: 50 }}>
<Slider min={0} max={100} stateKey="volume" />
</PlaygroundStateProvider>
);
// html contains `value="50"` — not `value="0"` (the local fallback)If your framework has a renderToReadableStream or similar, the same
guarantee holds. The hook count is 3 (down from 5 in 0.2.1)
thanks to useSyncExternalStore collapsing the previous
useState + useState + useEffect trio.
Slider — the three operating modes
The Slider component accepts value and/or stateKey. They mean
different things:
| Pass | Mode | Behavior |
|---------------------|--------------------------|---------------------------------------|
| Neither | Uncontrolled | Owns its own local state |
| value={50} only | Controlled, read-only | User input is ignored (no onChange wired) |
| stateKey="volume" | Bound to state | Reads/writes via the Provider |
The bound mode wins when both are present. value is clamped to
[min, max] on read; NaN clamps to min; min === max disables the
slider.
The CLI: playgenx-state
After npm install @playgenx/components, you get a playgenx-state
binary on your PATH (via npm's bin field). It operates on JSON
snapshots on stdin/stdout.
# Pretty-print a snapshot in the v1 envelope format
$ echo '{"volume":5,"user":{"name":"a"}}' | playgenx-state snapshot
{
"version": 1,
"keys": [
"user",
"volume"
],
"values": {
"volume": 5,
"user": { "name": "a" }
}
}
# Validate stateKey strings before using them in a body
$ echo '["good","bad key","has/slash"]' | playgenx-state validate
[
{ "key": "good", "ok": true },
{ "key": "bad key", "ok": false, "error": "stateKey must not contain whitespace" },
{ "key": "has/slash", "ok": false, "error": "stateKey contains forbidden characters..." }
]
# exit code 3 (validation failed)
# Diff two snapshots
$ echo '{"prev":{"a":1},"next":{"a":2,"b":3}}' | playgenx-state diff
{ "added": ["b"], "changed": ["a"], "removed": [] }
# Inspect keys / count
$ echo '{"b":1,"a":2,"c":3}' | playgenx-state keys
[ "a", "b", "c" ]
$ echo '{"b":1,"a":2,"c":3}' | playgenx-state count
{ "keys": 3 }Exit codes: 0 ok, 1 unknown subcommand, 2 bad input, 3
validation failure.
These are the same helpers exported from the package as library
functions (dumpState, diffSnapshots, validateStateKey, etc.) —
the CLI is a thin wrapper. Use whichever fits.
Architecture notes
- Per-Provider scope. Each
PlaygroundStateProvidercreates an independent store. Nested Providers create nested scopes (a child Provider does not see the parent's keys). - HMR-safe. Subscribers are cleared on Provider unmount, so a hot-reload that re-mounts the Provider doesn't leak subscribers to the old store.
- Microtask batched.
set()schedules subscriber fires on the next microtask. Multiple sets in the same tick produce one fire per affected key. Usebatch(state, fn)if you want each subscriber to see only the LAST value set in a group. - No deep clone. Values are stored by reference. Don't mutate
objects after
set()-ing them — use a fresh object instead. Object.isequality. Setting a value thatObject.is-equals the current value is a no-op (no subscriber fire, no flush). This meansset('x', NaN)twice fires once, andset('x', undefined)on an unset key is a no-op (sinceObject.is(undefined, undefined)istrue).- Sub-Provider remount drops old subscribers. Subscribers from a unmounted Provider's tree do not fire for sets on a NEW Provider's store, even with the same key name. (Verified by the A.7 hardening test.)
TypeScript
This package is written in TypeScript with strict types. The
dist/index.d.mts declarations are exhaustive — you can import
type { SliderProps, PlaygroundState, StateDiff } etc.
Common imports:
import type {
SliderProps,
ButtonProps,
PlaygroundState,
StateAction,
StateDiff,
StateEnvelope,
RenderBodyOptions,
} from '@playgenx/components';Versioning
- 0.1.x — Component-only, no state store.
Sliderhad nostateKeyprop. - 0.2.0 — Adds
PlaygroundStateProvider,useBoundValue*,useStateAction,ArtifactErrorBoundary,ShowSource,createRegistry, the pure helpers (dumpStateetc.), and theplaygenx-stateCLI binary. ThestateKeyprop onSlideris fully wired and uses the new state store. - 0.2.1 — Exposes the CLI binary via the package's
binfield sonpx playgenx-stateworks after a fresh install. - 0.2.2 — SSR fix:
useBoundValueOrUndefinednow uses React 19'suseSyncExternalStoresorenderToStringreflects the Provider's live value (no more "fallback on server, real value after hydration" mismatch). Adds three flexibility hooks:useStoreSnapshot(non-subscribing),useBoundSelector(slice-aware subscription),useMultiBoundValue(batch key binding). 108 tests passing.
License
MIT — same as the parent project.
