React State Management preview
Update a rendered view by changing its data, without replacing the layout. State bindings let a specification refer to values such as the traveler’s name or selected preference.
The native React APIs on this page are experimental, private source-build packages. Run the repository example to use them; they are not an npm installation recipe.
Let React own editable values
RenderSpec reads the state prop; it does not become the owner of your form. Update local React state in response to a control, then pass the next object to the renderer. A $state expression names the value to read. The local example below makes that relationship visible without a backend or persistence layer.
import { useState } from 'react';
import { RenderSpec, type ReactRenderRegistry, type RenderSpecData } from '@threadplane/react/render';
const registry: ReactRenderRegistry = { Text: ({ props }) => <p>{String(props['text'] ?? '')}</p> };
const spec: RenderSpecData = {
root: 'destination',
elements: { destination: { type: 'Text', props: { text: { $state: '/destination' } } } },
};
export function DestinationPreview() {
const [destination, setDestination] = useState('Portland');
return <>
<input aria-label="Destination" value={destination} onChange={(event) => setDestination(event.target.value)} />
<RenderSpec spec={spec} registry={registry} state={{ destination }} />
</>;
}The React conversation walkthrough connects this capability to requests, tools, UI state, and saved history.
Edit local state
Choose Run, select User Profile, Nested Paths or Form Display, and press Finish. Name, Age and Theme sit outside the rendered tree and update its visible values. Age accepts whole numbers from 0 to 150; an invalid draft keeps the last valid value until you correct it. Theme changes the displayed preference. Reloading restores Alice, age 30, and Dark.
Play streams the JSON locally. Pause holds the position, Playback position seeks, Finish displays the full sample, and Reset clears playback. Your state edits survive these controls and sample changes. No agent requests or saved browser state are used.
export const STATE_SAMPLES: readonly {
readonly label: string;
readonly json: string;
}[] = [
{
label: 'User Profile',
json: JSON.stringify(
{
root: 'root',
elements: {
root: {
type: 'Heading',
props: { content: 'User Profile' },
children: ['name', 'age'],
},
name: {
type: 'Text',
props: { content: { $state: '/user/name' } },
},
age: {
type: 'Text',
props: { content: { $state: '/user/age' } },
},
},
},
null,
2
),
},
{
label: 'Nested Paths',
json: JSON.stringify(
{
root: 'root',
elements: {
root: {
type: 'Heading',
props: { content: 'Nested State Paths' },
children: ['userName', 'userAge', 'theme'],
},
userName: {
type: 'Label',
props: { label: 'Name', value: { $state: '/user/name' } },
},
userAge: {
type: 'Label',
props: { label: 'Age', value: { $state: '/user/age' } },
},
theme: {
type: 'Label',
props: { label: 'Theme', value: { $state: '/settings/theme' } },
},
},
},
null,
2
),
},
{
label: 'Form Display',
json: JSON.stringify(
{
root: 'root',
elements: {
root: {
type: 'Card',
props: { title: 'State-Driven Form' },
children: ['nameField', 'themeField'],
},
nameField: {
type: 'Label',
props: { label: 'User', value: { $state: '/user/name' } },
},
themeField: {
type: 'Label',
props: {
label: 'Preference',
value: { $state: '/settings/theme' },
},
},
},
},
null,
2
),
},
];Own state in the React host
React useState holds copied, frozen user and settings data. The host passes it through RenderSpec's state prop. Text and Label props use a one-key expression such as { $state: '/user/name' }; the native renderer resolves the value. The three authored paths are /user/name, /user/age and /settings/theme.
import type { RenderState } from '@threadplane/content/render-state';
export type DemoState = {
readonly user: { readonly name: string; readonly age: number };
readonly settings: { readonly theme: 'dark' | 'light' };
};
// Closed application contract: initialized with createInitialState and maintained
// by validated scalar edits. This does not type arbitrary render-state owners.
export type DemoObserver = {
readonly getSnapshot: () => DemoState;
readonly subscribe: (notify: () => void) => () => void;
};
export type StateField = 'name' | 'age' | 'theme';
export type StateEditor = (field: StateField, value: unknown) => string | null;
type ValidatedEdit =
| { readonly error: string }
| {
readonly error: null;
readonly path: '/user/name';
readonly value: string;
}
| { readonly error: null; readonly path: '/user/age'; readonly value: number }
| {
readonly error: null;
readonly path: '/settings/theme';
readonly value: 'dark' | 'light';
};
function validateEdit(field: StateField, value: unknown): ValidatedEdit {
if (field === 'name') {
if (typeof value !== 'string' || value.length > 256)
return { error: 'Use at most 256 characters for the name.' };
return { error: null, path: '/user/name', value };
}
if (field === 'age') {
if (
typeof value !== 'string' ||
!/^[0-9]{1,3}$/.test(value) ||
Number(value) > 150
)
return { error: 'Enter a whole age from 0 to 150.' };
return { error: null, path: '/user/age', value: Number(value) };
}
if (value !== 'dark' && value !== 'light')
return { error: 'Choose Dark or Light.' };
return { error: null, path: '/settings/theme', value };
}
export function createStateEditor(
owner: Pick<RenderState, 'set'>
): StateEditor {
return (field, value) => {
const edit = validateEdit(field, value);
if (edit.error !== null) return edit.error;
owner.set(edit.path, edit.value);
return null;
};
}
function own(state: DemoState): DemoState {
return Object.freeze({
user: Object.freeze({ ...state.user }),
settings: Object.freeze({ ...state.settings }),
});
}
export function createInitialState(): DemoState {
return own({ user: { name: 'Alice', age: 30 }, settings: { theme: 'dark' } });
}
export function editState(
state: DemoState,
field: StateField,
value: unknown
): { readonly state: DemoState; readonly error: string | null } {
const edit = validateEdit(field, value);
if (edit.error !== null) return Object.freeze({ state, error: edit.error });
const next =
edit.path === '/user/name'
? own({ ...state, user: { ...state.user, name: edit.value } })
: edit.path === '/user/age'
? own({ ...state, user: { ...state.user, age: edit.value } })
: own({ ...state, settings: { theme: edit.value } });
return Object.freeze({ state: next, error: null });
}// eslint-disable-next-line @nx/enforce-module-boundaries -- The isolated consumer copies this internal example layout.
import { ExampleLayout } from '../../../../shared/react-example';
import { useEffect, useState, useSyncExternalStore } from 'react';
import { useAgent } from '@threadplane/react';
import { RenderSpec } from '@threadplane/react/render';
import type { Playback } from './playback';
import { localRegistry } from './views';
import type { DemoObserver, StateEditor } from './state';
export function StateDemo({
playback,
renderState,
onStateEdit,
samples,
onReady,
}: {
readonly playback: Playback;
readonly renderState: DemoObserver;
readonly onStateEdit: StateEditor;
readonly samples: readonly string[];
readonly onReady: () => void;
}) {
const host = useAgent(renderState);
const [ageInput, setAgeInput] = useState(() => ({
observer: renderState,
age: host.user.age,
draft: String(host.user.age),
error: null as string | null,
}));
// React retries this component before committing its children. Keeping this
// presentation in React state retires accepted spellings on committed changes
// without mutating refs or the owner during rendering.
if (
ageInput.observer !== renderState ||
(ageInput.error === null && ageInput.age !== host.user.age)
) {
setAgeInput({
observer: renderState,
age: host.user.age,
draft: String(host.user.age),
error: null,
});
}
const ageDraft = ageInput.draft;
const ageError = ageInput.error;
const state = useSyncExternalStore(
playback.subscribe,
playback.getSnapshot,
playback.getSnapshot
);
useEffect(onReady, [onReady]);
const phase =
state.phase === 'error'
? 'Unavailable'
: state.phase[0].toUpperCase() + state.phase.slice(1);
return (
<ExampleLayout kind="render" className="spec-demo">
<header>
<h1>State Management</h1>
</header>
<p>
Edit the local state and watch its values appear in the view. Playback
controls keep your edits.
</p>
<section aria-label="State controls" className="controls">
<label>
Name
<input
type="text"
maxLength={256}
value={host.user.name}
onChange={(event) => {
const value = event.target.value;
onStateEdit('name', value);
}}
/>
</label>
<label>
Age
<input
type="number"
min={0}
max={150}
step={1}
value={ageDraft}
aria-invalid={ageError !== null}
aria-describedby={ageError ? 'age-error' : undefined}
onChange={(event) => {
const draft = event.target.value;
const error = onStateEdit('age', draft);
setAgeInput({
observer: renderState,
draft,
error,
age: error === null ? Number(draft) : host.user.age,
});
}}
/>
</label>
<label>
Theme
<select
value={host.settings.theme}
onChange={(event) => {
const value = event.target.value;
onStateEdit('theme', value);
}}
>
<option value="dark">Dark</option>
<option value="light">Light</option>
</select>
</label>
{ageError && (
<p id="age-error" role="alert">
{ageError}
</p>
)}
<p>
Theme changes the displayed value. Reloading restores Alice, age 30,
and Dark.
</p>
</section>
<section aria-label="Playback controls" className="controls">
<label>
Sample
<select
value={state.selected}
onChange={(event) => playback.select(Number(event.target.value))}
>
{samples.map((label, index) => (
<option key={index} value={index}>
{label}
</option>
))}
</select>
</label>
<div className="buttons">
<button
type="button"
disabled={state.phase === 'error'}
onClick={state.playing ? playback.pause : playback.play}
>
{state.playing ? 'Pause' : 'Play'}
</button>
<button
type="button"
disabled={state.phase === 'error'}
onClick={playback.finish}
>
Finish
</button>
<button type="button" onClick={playback.reset}>
Reset
</button>
</div>
<label>
Playback position
<input
type="range"
min={0}
max={state.total}
step={1}
value={state.position}
disabled={state.phase === 'error'}
onChange={(event) => playback.seek(Number(event.target.value))}
/>
</label>
<p role="status">
{phase} · {state.position} characters
</p>
{state.phase === 'error' && (
<p role="alert">
This sample could not be displayed. Choose another sample to
continue.
</p>
)}
</section>
<div className="panels">
<section aria-label="Render output">
<h2>Render output</h2>
{state.spec ? (
<RenderSpec
spec={state.spec}
registry={localRegistry}
state={host}
loading={state.position < state.total}
/>
) : (
<p className="empty">Play a sample to see its view.</p>
)}
</section>
<section aria-label="Streaming JSON">
<h2>Streaming JSON</h2>
<pre>{state.rawJson}</pre>
</section>
</div>
</ExampleLayout>
);
}Heading, Text, Label and Card are read-only registered views. Heading and Card display children supplied by RenderSpec in authored order. These views do not dispatch actions or write state.
import type {
ReactRenderRegistry,
RenderViewProps,
} from '@threadplane/react/render';
function display(value: unknown): string | number {
return typeof value === 'string' ||
(typeof value === 'number' && Number.isFinite(value))
? value
: '';
}
function Heading({ props, children, loading }: RenderViewProps) {
return (
<div className="render-heading">
<h3 aria-busy={loading}>
{display(props.content) || (loading ? '…' : '')}
</h3>
{children}
</div>
);
}
function Text({ props, loading }: RenderViewProps) {
const value = display(props.content);
return <p aria-busy={loading}>{value === '' && loading ? '…' : value}</p>;
}
function Label({ props, loading }: RenderViewProps) {
const value = display(props.value);
return (
<p className="render-label" aria-busy={loading}>
<strong>{display(props.label)}</strong>
<span>{value === '' && loading ? '…' : value}</span>
</p>
);
}
function Card({ props, children, loading }: RenderViewProps) {
return (
<article className="render-card">
<h4 aria-busy={loading}>
{display(props.title) || (loading ? '…' : '')}
</h4>
{children}
</article>
);
}
export const localRegistry: ReactRenderRegistry = Object.freeze({
Heading,
Text,
Label,
Card,
});Keep partial bindings bounded
The caller admits a complete local sample before playback, then copies known partial data. A binding waits until its exact authored path is available. Unsupported paths, extra expression keys and executable fields are rejected. The renderer receives owned data; parser state stays private.
import type {
RenderElementData,
RenderSpecData,
} from '@threadplane/react/render';
const TYPES = ['Heading', 'Text', 'Card', 'Label'];
const ID = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/;
const MAX_NODES = 32;
const MAX_TEXT = 16 * 1024;
const PATHS = ['/user/name', '/user/age', '/settings/theme'];
function binding(
input: unknown,
complete: boolean
): Readonly<{ $state: string }> | undefined {
const expression = record(input);
fields(expression, ['$state']);
const path = expression.$state;
if (
!complete &&
(path === undefined ||
(typeof path === 'string' &&
!PATHS.includes(path) &&
PATHS.some((known) => known.startsWith(path))))
)
return undefined;
if (typeof path !== 'string' || !PATHS.includes(path)) invalid();
return Object.freeze({ $state: path });
}
function invalid(): never {
throw new TypeError('Invalid local render data.');
}
function record(input: unknown): Record<string, unknown> {
if (
input === null ||
typeof input !== 'object' ||
Array.isArray(input) ||
![null, Object.prototype].includes(Object.getPrototypeOf(input)) ||
Object.getOwnPropertySymbols(input).length
)
invalid();
const entries = Object.entries(Object.getOwnPropertyDescriptors(input));
if (entries.length > MAX_NODES) invalid();
const result = Object.create(null) as Record<string, unknown>;
for (const [key, descriptor] of entries) {
if (!descriptor.enumerable || !('value' in descriptor)) invalid();
Object.defineProperty(result, key, {
value: descriptor.value,
enumerable: true,
});
}
return result;
}
function fields(
input: Record<string, unknown>,
allowed: readonly string[]
): void {
if (Object.keys(input).some((key) => !allowed.includes(key))) invalid();
}
function references(input: unknown, complete: boolean): readonly string[] {
if (input === undefined && !complete) return [];
if (
!Array.isArray(input) ||
Object.getPrototypeOf(input) !== Array.prototype ||
input.length > MAX_NODES ||
Object.getOwnPropertySymbols(input).length
)
invalid();
const descriptors = Object.getOwnPropertyDescriptors(input);
for (const [key, descriptor] of Object.entries(descriptors))
if (
!('value' in descriptor) ||
(key !== 'length' && !/^(0|[1-9][0-9]*)$/.test(key))
)
invalid();
const result: string[] = [];
for (let index = 0; index < input.length; index++) {
const value = descriptors[String(index)]?.value;
if ((value === undefined || value === '') && !complete) continue;
if (typeof value !== 'string' || !ID.test(value) || result.includes(value))
invalid();
result.push(value);
}
return result;
}
function project(input: unknown, complete: boolean): RenderSpecData | null {
if (input === null && !complete) return null;
const source = record(input);
fields(source, ['root', 'elements']);
if (
source.root === undefined ||
source.root === '' ||
source.elements === undefined
) {
if (complete) invalid();
return null;
}
if (typeof source.root !== 'string' || !ID.test(source.root)) invalid();
const sourceElements = record(source.elements);
const elements = Object.create(null) as Record<string, RenderElementData>;
const childLists = new Map<string, readonly string[]>();
let textSize = 0;
for (const [key, inputElement] of Object.entries(sourceElements)) {
if (!ID.test(key)) invalid();
if (inputElement === undefined && !complete) continue;
const element = record(inputElement);
fields(element, ['type', 'props', 'children']);
if (element.type === undefined && !complete) continue;
if (typeof element.type !== 'string') invalid();
if (!TYPES.includes(element.type)) {
if (complete) invalid();
continue;
}
const rawProps = element.props === undefined ? {} : record(element.props);
fields(
rawProps,
element.type === 'Card'
? ['title']
: element.type === 'Label'
? ['label', 'value']
: ['content']
);
const props = Object.create(null) as Record<
string,
string | number | Readonly<{ $state: string }>
>;
for (const [prop, value] of Object.entries(rawProps)) {
if (value === undefined && !complete) continue;
const dynamic =
(element.type === 'Text' && prop === 'content') ||
(element.type === 'Label' && prop === 'value');
let copied: string | number | Readonly<{ $state: string }> | undefined;
if (typeof value === 'string') {
textSize += value.length;
copied = value;
} else if (dynamic && typeof value === 'number' && Number.isFinite(value))
copied = value;
else if (dynamic && value !== null && typeof value === 'object') {
copied = binding(value, complete);
if (copied) textSize += copied.$state.length;
} else invalid();
if (textSize > MAX_TEXT) invalid();
if (copied !== undefined)
Object.defineProperty(props, prop, { value: copied, enumerable: true });
}
const children = Object.hasOwn(element, 'children')
? references(element.children, complete)
: undefined;
if (children) childLists.set(key, children);
Object.defineProperty(elements, key, {
value: { type: element.type, props: Object.freeze(props) },
enumerable: true,
});
}
for (const [key, children] of childLists) {
if (complete && children.some((child) => !Object.hasOwn(elements, child)))
invalid();
Object.defineProperty(elements[key], 'children', {
value: Object.freeze(
children.filter((child) => Object.hasOwn(elements, child))
),
enumerable: true,
});
}
const heights = new Map<string, number>();
const active = new Set<string>();
function height(key: string): number {
const known = heights.get(key);
if (known !== undefined) return known;
if (active.has(key)) invalid();
active.add(key);
let result = 1;
for (const child of elements[key].children ?? [])
result = Math.max(result, 1 + height(child));
active.delete(key);
if (result > 16) invalid();
heights.set(key, result);
return result;
}
for (const key of Object.keys(elements)) {
height(key);
Object.freeze(elements[key]);
}
if (!Object.hasOwn(elements, source.root)) {
if (complete) invalid();
return null;
}
return Object.freeze({
root: source.root,
elements: Object.freeze(elements),
});
}
/** Copy the known display subset; the incremental parser retains its own mutable tree. */
export function projectPartialSpec(input: unknown): RenderSpecData | null {
return project(input, false);
}
/** Admit a complete authored local sample before any playback begins. */
export function validateCompleteSpec(input: unknown): RenderSpecData {
return project(input, true) ?? invalid();
}The private playback owner rebuilds its parser on seek and revokes obsolete callbacks on Pause, Reset, selection changes and page exit. Complete requires the finished parser output to match the admitted source. Playback never writes host state.
import type { RenderSpecData } from '@threadplane/react/render';
import { createPartialJsonParser, materialize } from '@cacheplane/partial-json';
import { projectPartialSpec, validateCompleteSpec } from './projection';
export interface PlaybackSample {
readonly label: string;
readonly json: string;
}
export interface FrameScheduler {
request(callback: FrameRequestCallback): number;
cancel(id: number): void;
}
export interface PlaybackSnapshot {
readonly selected: number;
readonly label: string;
readonly position: number;
readonly total: number;
readonly phase: 'paused' | 'playing' | 'complete' | 'error';
readonly playing: boolean;
readonly rawJson: string;
readonly spec: RenderSpecData | null;
}
export interface Playback {
getSnapshot(): PlaybackSnapshot;
subscribe(listener: () => void): () => void;
play(): void;
pause(): void;
seek(position: number): void;
select(index: number): void;
finish(): void;
reset(): void;
dispose(): void;
}
export function createPlayback(
samples: readonly PlaybackSample[],
scheduler: FrameScheduler = {
request: (callback) => requestAnimationFrame(callback),
cancel: (id) => cancelAnimationFrame(id),
}
): Playback {
const owned = samples.map((sample) => ({
label: sample.label,
json: sample.json,
}));
const listeners = new Set<() => void>();
let live = true;
let generation = 0;
let frame: number | undefined;
let parser = createPartialJsonParser();
let admitted: RenderSpecData | null = null;
let snapshot: PlaybackSnapshot;
function revoke(): number {
generation++;
if (frame !== undefined) scheduler.cancel(frame);
frame = undefined;
return generation;
}
function publish(next: PlaybackSnapshot): void {
snapshot = Object.freeze(next);
const current = generation;
for (const listener of [...listeners]) {
if (!live || current !== generation) break;
if (!listeners.has(listener)) continue;
try {
listener();
} catch {
/* A subscriber cannot take ownership of playback. */
}
}
}
function initialize(selected: number, notify: boolean): void {
parser = createPartialJsonParser();
admitted = null;
const sample = owned[selected];
try {
if (!sample || sample.json.length > 16 * 1024)
throw Error('Invalid sample');
// Incremental parsers can retain malformed input as streaming forever.
admitted = validateCompleteSpec(JSON.parse(sample.json));
} catch {
/* Only authored, bounded complete JSON may be played. */
}
const next: PlaybackSnapshot = {
selected,
label: sample?.label ?? 'Unavailable sample',
position: 0,
total: sample?.json.length ?? 0,
phase: admitted ? 'paused' : 'error',
playing: false,
rawJson: '',
spec: null,
};
if (notify) publish(next);
else snapshot = Object.freeze(next);
}
function advance(position: number, playing: boolean): void {
const source = owned[snapshot.selected].json;
try {
parser.push(source.slice(snapshot.position, position));
const complete = position === source.length;
if (complete) parser.finish();
const value = parser.root ? materialize(parser.root) : null;
const spec = complete
? validateCompleteSpec(value)
: projectPartialSpec(value);
if (
complete &&
(parser.root?.status !== 'complete' ||
JSON.stringify(spec) !== JSON.stringify(admitted))
)
throw Error('Incomplete sample');
publish({
...snapshot,
position,
rawJson: source.slice(0, position),
spec,
playing: playing && !complete,
phase: complete ? 'complete' : playing ? 'playing' : 'paused',
});
} catch {
revoke();
publish({
...snapshot,
phase: 'error',
playing: false,
rawJson: '',
spec: null,
});
}
}
function schedule(current: number): void {
if (
!live ||
generation !== current ||
!snapshot.playing ||
frame !== undefined
)
return;
frame = scheduler.request(() => {
if (!live || generation !== current || !snapshot.playing) return;
frame = undefined;
advance(Math.min(snapshot.total, snapshot.position + 4), true);
schedule(current);
});
}
function seek(position: number): void {
if (!live || !Number.isFinite(position) || !admitted) return;
revoke();
parser = createPartialJsonParser();
const target = Math.max(0, Math.min(snapshot.total, Math.floor(position)));
// Rebuild privately, then publish once so a subscriber cannot interrupt a reset.
snapshot = Object.freeze({
...snapshot,
position: 0,
rawJson: '',
spec: null,
playing: false,
phase: 'paused',
});
advance(target, false);
}
initialize(0, false);
return {
getSnapshot: () => snapshot,
subscribe(listener) {
if (live) listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
play() {
if (!live || !admitted || snapshot.playing) return;
const current = revoke();
if (snapshot.phase === 'complete') {
parser = createPartialJsonParser();
snapshot = Object.freeze({
...snapshot,
position: 0,
rawJson: '',
spec: null,
});
}
publish({ ...snapshot, playing: true, phase: 'playing' });
schedule(current);
},
pause() {
if (!live || !snapshot.playing) return;
revoke();
publish({ ...snapshot, playing: false, phase: 'paused' });
},
seek,
select(index) {
if (
!live ||
!Number.isInteger(index) ||
index < 0 ||
index >= owned.length
)
return;
revoke();
initialize(index, true);
},
finish() {
seek(snapshot.total);
},
reset() {
seek(0);
},
dispose() {
if (!live) return;
live = false;
revoke();
listeners.clear();
snapshot = Object.freeze({
...snapshot,
playing: false,
phase: snapshot.phase === 'playing' ? 'paused' : snapshot.phase,
});
},
};
}Source build and verification
From a Threadplane checkout with dependencies installed:
npx nx build cockpit-render-state-management-react
npx nx e2e cockpit-render-state-management-reactThe isolated build installs local React, core and content artifacts with root-locked presentation dependencies. Browser coverage exercises live bindings, invalid age recovery, partial playback, state retention, cleanup and narrow screens. Select Angular to read and run its existing state-store implementation.