@continuum-dev/react
v0.3.0
Published
React bindings for the Continuum continuity runtime
Downloads
617
Maintainers
Readme
♾️ @continuum-dev/react
Build interfaces that can change at runtime without hand-building the continuity layer yourself.
Website: continuumstack.dev GitHub: brytoncooper/continuum-dev
Core Premise: The Ephemerality Gap
The Ephemerality Gap is the mismatch between ephemeral, regenerating interfaces and durable user intent. Continuum keeps UI structure and user state separate, then uses deterministic reconciliation so user intent survives schema changes.
Most React apps are written with one giant assumption: the UI shape is basically fixed.
That works great right up until your app starts doing things like:
- generating UI from AI output
- assembling screens from schemas
- rebuilding flows from workflow state
- streaming new layouts mid-session
- rendering dynamic forms that need persistence, undo, and conflict handling
At that point, the hard part is no longer just rendering data.
The hard part becomes:
- keeping user input attached to changing UI
- preserving state across structural changes
- preventing system updates from clobbering in-progress edits
- wiring nested collections without inventing a weird local-state framework
- recovering safely when generated nodes are incomplete, invalid, or unexpected
That is the hole Continuum fills.
@continuum-dev/react is the headless React layer for Continuum: a system for building interfaces that evolve at runtime while still feeling stable, stateful, and production-grade.
It is the point where the lower-level Continuum model exposed through @continuum-dev/core turns into a real user experience.
You bring your own components.
Continuum handles the continuity layer underneath.
The "ohhhh" moment
Without Continuum
Your app receives a new UI shape.
Now you need custom logic for:
- mapping changing nodes back to live state
- preserving user edits across view updates
- hydrating and persisting dynamic session state
- resolving user edits vs system updates
- handling nested collection item state
- preventing one broken dynamic node from blanking the whole screen
That usually starts as "just a few helpers" and quietly turns into a strange in-house framework.
With Continuum
You:
- push a new
ViewDefinition - render it through your React component map
- read and update node state with hooks
- persist and hydrate the session
- resolve conflicts and suggestions when needed
- get collections, diagnostics, fallbacks, and per-node error isolation built in
Same React.
Same design system.
Way less glue code.
What Continuum actually is
Continuum splits the problem into clear layers:
@continuum-dev/corebundles the lower-level contract, runtime, and session layers@continuum-dev/reactrenders that model into React
This package is the React binding.
It does not replace your design system.
It does not force a visual style.
It does not own your app shell.
It gives you a better runtime model for interfaces that are not fully static.
Why this feels different
It renders change, not just data
Most libraries help you render a structure once.
Continuum is built for cases where the structure itself evolves during the session. The job is not just to render the latest tree. The job is to keep the interface usable while that tree changes.
It is built to stay fast as views grow
@continuum-dev/react uses an external-store fan-out model powered by useSyncExternalStore.
In practice, that means updates stay focused: components subscribe to specific session state instead of forcing broad rerenders across the whole dynamic view.
It fails safer
Every rendered node is wrapped in NodeErrorBoundary.
If one dynamic component blows up because a generated node is malformed or unexpected, the rest of the screen can keep working.
It stays headless
Continuum provides structure, reconciliation, and session wiring.
You keep full control over components, branding, styling, and UX.
Install
npm install @continuum-dev/react @continuum-dev/core reactPeer dependency: react >= 18.
60-second example
This example maps your design system components to dynamic node types and renders a live Continuum view.
import { useEffect } from 'react';
import type { NodeValue, ViewDefinition } from '@continuum-dev/core';
import {
ContinuumProvider,
ContinuumRenderer,
useContinuumSession,
useContinuumSnapshot,
type ContinuumNodeMap,
type ContinuumNodeProps,
} from '@continuum-dev/react';
const components: ContinuumNodeMap = {
field: ({ value, onChange, definition }: ContinuumNodeProps) => (
<label style={{ display: 'grid', gap: 6 }}>
<span>{definition.label ?? definition.key ?? definition.id}</span>
<input
value={typeof value?.value === 'string' ? value.value : ''}
onChange={(e) =>
onChange({
value: e.target.value,
isDirty: true,
} as NodeValue)
}
/>
</label>
),
group: ({ children }: ContinuumNodeProps) => (
<section style={{ display: 'grid', gap: 12 }}>{children}</section>
),
};
const initialView: ViewDefinition = {
viewId: 'demo',
version: '1',
nodes: [
{
id: 'profile',
type: 'group',
children: [
{
id: 'email',
type: 'field',
dataType: 'string',
key: 'user.email',
label: 'Email',
},
],
},
],
};
function Screen() {
const session = useContinuumSession();
const snapshot = useContinuumSnapshot();
useEffect(() => {
if (!session.getSnapshot()) {
session.pushView(initialView);
}
}, [session]);
if (!snapshot?.view) {
return null;
}
return <ContinuumRenderer view={snapshot.view} />;
}
export function App() {
return (
<ContinuumProvider components={components} persist="localStorage">
<main>
<h1>Profile</h1>
<Screen />
</main>
</ContinuumProvider>
);
}Mental model
There are three big ideas behind this package.
You render views, not hardcoded screens.
The renderer walks aViewDefinitiontree and resolves each node through your component map.State lives in the session.
Your components read current values from the active Continuum session and write updates back into it.Dynamic interfaces need production behavior.
Persistence, diagnostics, conflict handling, collection state, fallbacks, and recovery are core parts of the model.
Core API
ContinuumProvider
ContinuumProvider creates and owns a Continuum session for a React subtree. It can:
- create a fresh session
- hydrate from storage
- expose session and internal store through context
- persist updates back to storage
- stay stable through React Strict Mode replay behavior
<ContinuumProvider components={components} persist="localStorage">
<App />
</ContinuumProvider>Props
| Prop | Type | Description |
| --- | --- | --- |
| components | ContinuumNodeMap | Required map of node type to React component. |
| persist | 'localStorage' \| 'sessionStorage' \| false | Optional browser storage strategy. |
| storageKey | string | Optional storage key. Default: continuum_session. |
| maxPersistBytes | number | Optional max serialized payload size before persistence is skipped. |
| onPersistError | (error: ContinuumPersistError) => void | Optional callback for size_limit and storage_error. |
| sessionOptions | SessionOptions | Optional session configuration passed to hydration/creation. |
| children | React.ReactNode | React subtree rendered inside the provider. |
ContinuumRenderer
Renders a ViewDefinition tree through your component map.
<ContinuumRenderer view={snapshot.view} />What it does
- resolves node components by
definition.type - falls back to
components.defaultif provided - otherwise uses built-in
FallbackComponent - wraps each node in
NodeErrorBoundary - supports hidden nodes
- supports nested container nodes
- supports built-in collection behavior
- passes canonical scoped
nodeIdvalues to your components
Hooks
useContinuumState(nodeId)
Primary hook for data-bearing components.
const [value, setValue] = useContinuumState('user_email');useContinuumConflict(nodeId)
Use this when system proposals should not overwrite in-progress user edits automatically.
const { hasConflict, proposal, accept, reject } = useContinuumConflict('user_email');useContinuumDiagnostics()
Returns timeline and reconciliation metadata:
issuesdiffsresolutionscheckpoints
const { issues, checkpoints } = useContinuumDiagnostics();useContinuumViewport(nodeId)
Tracks non-data state (focus, expansion, scroll, zoom, offsets) inside the session model.
const [viewport, setViewport] = useContinuumViewport('table');If this hook is called from inside a collection-item scope, Continuum logs a development warning because viewport state is not currently scoped per collection item.
useContinuumSession()
Returns the active session for full session API access.
const session = useContinuumSession();useContinuumSnapshot()
Subscribes to the full current ContinuitySnapshot.
const snapshot = useContinuumSnapshot();Snapshots are delivered as immutable top-level copies so consumer code cannot accidentally mutate session internals.
useContinuumHydrated()
Indicates whether provider initialization came from persisted storage.
const hydrated = useContinuumHydrated();useContinuumSuggestions()
Scans current snapshot values for suggestions and provides accept-all / reject-all actions.
const { hasSuggestions, acceptAll, rejectAll } = useContinuumSuggestions();useContinuumAction(intentId)
Handles action dispatch with built-in loading and result state.
const { dispatch, isDispatching, lastResult } = useContinuumAction('submit_form');When multiple dispatches overlap, isDispatching and lastResult reflect the latest in-flight dispatch.
Example action component:
function SubmitButton({ definition }: ContinuumNodeProps) {
const intentId = definition.intentId ?? '';
const { dispatch, isDispatching, lastResult } = useContinuumAction(intentId);
return (
<div>
<button disabled={isDispatching} onClick={() => dispatch(definition.id)}>
{isDispatching ? 'Working...' : definition.label}
</button>
{lastResult && (
<span>{lastResult.success ? 'Done' : 'Failed'}</span>
)}
</div>
);
}The node contract
Each component in your components map receives this prop shape:
import type { NodeValue, ViewNode } from '@continuum-dev/core';
interface ContinuumNodeProps<T = NodeValue> {
value: T | undefined;
onChange: (value: T) => void;
definition: ViewNode;
nodeId?: string;
children?: React.ReactNode;
[prop: string]: unknown;
}nodeId is the canonical scoped id used by the renderer.
For nested nodes, it can look like group/field.
Example: conflict UI
import { useContinuumConflict } from '@continuum-dev/react';
function EmailConflict({ nodeId }: { nodeId: string }) {
const { hasConflict, proposal, accept, reject } = useContinuumConflict(nodeId);
if (!hasConflict) {
return null;
}
return (
<div>
<div>Suggested value: {String(proposal?.value ?? '')}</div>
<button onClick={accept}>Accept</button>
<button onClick={reject}>Reject</button>
</div>
);
}Example: suggestion banner
import { useContinuumSuggestions } from '@continuum-dev/react';
function SuggestionBanner() {
const { hasSuggestions, acceptAll, rejectAll } = useContinuumSuggestions();
if (!hasSuggestions) {
return null;
}
return (
<div>
<span>Suggested updates are available.</span>
<button onClick={acceptAll}>Accept all</button>
<button onClick={rejectAll}>Reject all</button>
</div>
);
}Example: undo with checkpoints
import { useContinuumDiagnostics, useContinuumSession } from '@continuum-dev/react';
function UndoButton() {
const session = useContinuumSession();
const { checkpoints } = useContinuumDiagnostics();
const undo = () => {
const previous = checkpoints[checkpoints.length - 2];
if (previous) {
session.rewind(previous.checkpointId);
}
};
return <button onClick={undo}>Undo last change</button>;
}Collections
@continuum-dev/react includes built-in collection node support:
- initial item creation from
minItems - add behavior constrained by
maxItems - remove behavior constrained by
minItems - scoped item state storage
- default template values
- canonical nested ids for collection children
- headless control wiring through your own collection components
Collection controls are now passed as props to your mapped components:
- collection root components receive
onAdd,canAdd,onRemove, andcanRemove - template root components receive
itemIndex,onRemove, andcanRemove - no renderer-owned wrapper elements or
data-continuum-*control attributes are injected
This keeps collection behavior built in while letting your design system fully own the markup and styles.
Fallbacks and failure isolation
Dynamic interfaces are messy in real production environments. This package is designed to fail more safely.
Unknown node types
Node resolution order:
components[definition.type]components.default- built-in
FallbackComponent
The fallback renders:
- unknown node type information
- editable text input when possible
- raw node definition for diagnostics
Per-node error boundaries
Every rendered node is wrapped in NodeErrorBoundary.
If one component crashes while rendering a dynamic node, sibling regions can keep working. When a later rerender provides recoverable children, the boundary resets and the node can render again.
Persistence behavior
When persist is enabled, provider-level session persistence supports:
- hydration on provider creation
- persistence writes through the session layer
- optional payload size limits with
maxPersistBytes - optional
onPersistErrorcallback for:size_limitstorage_error
Supported storage targets:
localStoragesessionStorage
Example:
<ContinuumProvider
components={components}
persist="localStorage"
maxPersistBytes={100_000}
onPersistError={(error) => {
console.error(error);
}}
>
<App />
</ContinuumProvider>When to use this package
@continuum-dev/react is a strong fit when UI can change during a session and is driven by:
- AI output
- schemas
- workflows
- server-driven definitions
- dynamic internal tools
- resumable multi-step experiences
- long-lived interfaces where persistence and history matter
If your UI is fully static and your state model is simple, you may not need Continuum.
Ecosystem
Continuum packages:
@continuum-dev/core: lower-level contract, runtime, and session facade@continuum-dev/react: React bindings and renderer@continuum-dev/starter-kit: opinionated primitives and proposal UI built on top of React
In one sentence
If React is your UI engine, @continuum-dev/react is the layer that lets dynamic, evolving interfaces stop feeling fragile.
License
MIT
