Nova · REACT_DOCS.md

This is REACT_DOCS.md as @niscorp/nova 0.5.1 ships it. A coding agent reads it from node_modules. The file itself:

nisc.dev/nova/REACT_DOCS.md

React Adapter — Author Guide

How to use nova in a React app. The React adapter is a separate subpath: @niscorp/nova/adapters/react. The core (@niscorp/nova) is framework-agnostic and never imports React. The adapter is the glue that turns nova's RenderNode[] output into actual React elements.

This guide is for using nova in React. For nova's framework-agnostic core, see LAYOUT_DOCS.md, ACTION_DOCS.md, and SHELL_DOCS.md.


Install

pnpm add @niscorp/nova
# `react` (^19.2.4) is an optional peer; a browser app brings `react-dom` itself.

@niscorp/nova/adapters/react is the same package, accessed via the subpath export. No separate install.


A first React app

import { createShell, createComponentRegistry, createLayoutStore } from '@niscorp/nova';
import { NovaShellProvider, useCanvas, useRenderTree, RenderTree } from '@niscorp/nova/adapters/react';
import { registerNovaReactComponents } from '@niscorp/nova/adapters/react/components';
import { useEffect, useState } from 'react';

const counter = {
  id: 'counter',
  data: { count: 0 },
  layout: {
    component: 'Stack',
    props: { direction: 'column', gap: 16, padding: 24 },
    children: [
      { component: 'Text', props: { size: 'xl', weight: 'bold' }, children: 'Count: {{$.count}}' },
      { component: 'Button', ref: 'inc', children: 'Increment' },
    ],
  },
  triggers: [
    { event: 'ui:click', ref: 'inc', do: [{ increment: 'count' }] },
  ],
};

const App = () => {
  const [shell] = useState(() => {
    const registry = createComponentRegistry();
    registerNovaReactComponents(registry);
    const layoutStore = createLayoutStore();
    return {
      shell: createShell({
        canvases: [{ id: 'main' }],
        registry,
        layoutStore,
        actions: { counter },
      }),
      registry,
    };
  });

  useEffect(() => {
    shell.shell.push('main', 'counter');
    return () => shell.shell.dispose();
  }, [shell]);

  return (
    <NovaShellProvider shell={shell.shell} registry={shell.registry}>
      <CanvasView canvasId="main" />
    </NovaShellProvider>
  );
};

const CanvasView = ({ canvasId }: { canvasId: string }) => {
  const canvas = useCanvas(canvasId);
  const tree = useRenderTree(canvas.active?.id ?? '');
  return <RenderTree nodes={tree} />;
};

This is a complete working React + nova counter. Click the button, the count goes up, the text re-renders.


The two providers

The React adapter has two providers because there are two ways to use nova in a React app:

<NovaShellProvider> — full shell mode

Use this when you have a Shell. The provider wires the shell's dispatch/publish into the render context so components can emit events back to the shell. Most apps use this.

<NovaShellProvider shell={shell} registry={registry}>
  <CanvasView canvasId="main" />
</NovaShellProvider>

Props:

  • shell — a Shell instance
  • registry? — the component registry. Defaults to shell.registry; pass one only to render the shell against a different component set.
  • slotWrapper? — see "The slotWrapper seam" below.
  • children — the React subtree that uses nova hooks.

<NovaRenderProvider> — render-only mode

Use this when you want to render a layout without a shell — for previews, tests, or the showroom. You supply the dispatch and publish functions yourself; both default to no-ops.

<NovaRenderProvider
  registry={registry}
  dispatch={(event) => console.log('dispatch:', event)}
  publish={(channel, payload) => console.log('publish:', channel, payload)}
>
  <RenderTree nodes={someStaticRenderTree} />
</NovaRenderProvider>

Props:

  • registry — the component registry
  • dispatch? — function to receive events from components (default: no-op)
  • publish? — function to receive message bus publishes from components (default: no-op)
  • slotWrapper? — see "The slotWrapper seam" below.
  • fallback? — renderer for unregistered component names; omit for strict COMPONENT_NOT_FOUND markers
  • textWrapper? / errorMarker? — host-specific leaf renderers. The defaults render a text node as a bare string and an error node as a <span>; a react host that is not the DOM (ink) supplies both — bare strings and DOM tags crash it. Browser consumers omit them.
  • children — the React subtree

This is what the showroom uses for layout-only stories that don't need a shell.

<Nova.Layout> / <Nova.Shell> / <Nova.Canvas> — mountable surfaces

Three components that hide the registry/provider/render-tree wiring. Each is also exported by its flat name (NovaLayout, NovaShell, NovaCanvas).

<Nova.Layout layout={layout} data={data} />   // one layout, no shell
<Nova.Shell shell={shell} />                  // the shell's whole frame
<Nova.Canvas id="main" />                     // one canvas, inside a shell provider
  • <Nova.Layout> — layout (a LayoutNode or a layout-store id), data?, components? (registered after the builtins), registry? / store? (pre-built; with registry, components and builtins are ignored), builtins? (default true), strict?, onError?, and the i18n fields phrases? / phraseKeys? / onPhraseMiss? (see I18N_DOCS.md). Renders through a NovaRenderProvider with no-op dispatch. A layout given as an id is read from store, so pass the store that holds it; an id it does not hold renders nothing.
  • <Nova.Shell> — shell, registry? (default shell.registry), slotWrapper?, builtins? (default true: registers the default component set on the registry if CanvasSlot is missing). Renders the shell's canvasLayout.
  • <Nova.Canvas> — id, plus optional shell, registry?, builtins?. With shell it provides its own NovaShellProvider; without, it must sit inside one. Renders that canvas's actionLayout.

Hooks

All hooks must be called inside one of the providers above.

useShell()

Returns the active Shell. Throws if called outside a <NovaShellProvider>.

const shell = useShell();
shell.dispatch({ type: 'ui:click', ref: 'manual' });

useShellState()

Returns the current StateSnapshot. Re-renders the calling component whenever any canvas changes. Backed by useSyncExternalStore so it's tearing-safe under React concurrent rendering.

const state = useShellState();
const mainStack = state.canvases.main?.stack ?? [];

useCanvas(canvasId)

Returns the CanvasState for one canvas. Re-renders only when this specific canvas's stack or active instance changes.

const canvas = useCanvas('main');
const activeId = canvas.active?.id;

useActionData(instanceId)

Returns the live data for an action instance. Re-renders whenever that instance's data changes. Returns undefined if the instance doesn't exist.

const data = useActionData(activeId);

useActionStatus(instanceId)

Returns the action's status ('initializing' | 'active' | 'suspended' | 'unmounted').

const status = useActionStatus(activeId);
if (status === 'initializing') return <Spinner />;

useRenderTree(instanceId)

Returns the live RenderNode[] for an action instance. Re-computes whenever the instance's data (or the shell's phrasebook) changes. The result is referentially stable when neither has changed — important for React's useSyncExternalStore and for downstream memoization.

const tree = useRenderTree(activeId);
return <RenderTree nodes={tree} />;

useShellRenderTree() / useCanvasRenderTree(canvasId)

Return the live RenderNode[] for the shell's canvasLayout, or for one canvas's actionLayout. Re-compute on shell state change; the result is referentially stable while nothing changed. useCanvasRenderTree(undefined) returns a stable empty array. These are what CanvasSlot-style chrome uses; most apps render through CanvasSlot/ActionSlot components instead.

const frame = useShellRenderTree();
const canvasTree = useCanvasRenderTree('main');

useNovaDispatch() / useNovaPublish()

Inside a registered component, call these to send events back to the shell. They read from the render context, not the shell context, so they work in both NovaShellProvider and NovaRenderProvider modes.

const Button: NovaComponent<{ label?: string }> = ({ label, novaRef, children }) => {
  const dispatch = useNovaDispatch();
  return (
    <button onClick={() => novaRef && dispatch({ type: 'ui:click', ref: novaRef })}>
      {label ?? children}
    </button>
  );
};

useNovaRegistry()

Returns the active component registry. Useful for components that need to look up other components by name.


<RenderTree> and component instantiation

<RenderTree nodes={...} /> walks a RenderNode[] array and renders each node. It dispatches:

  • text nodes → render their value as text
  • fragment nodes → recurse into children
  • error nodes → render via <ErrorMarker>
  • component nodes → look up the component in the registry and instantiate it
const tree = useRenderTree(activeId);
return <RenderTree nodes={tree} />;

An unregistered component name renders the provider's fallback when one was given, otherwise an error marker with code COMPONENT_NOT_FOUND. <RenderNodeView node={...} /> is the single-node form RenderTree maps over.

That's the entire React-side rendering pipeline. Everything else is the components themselves.


Writing your own component

A component for the React adapter is a React component typed as NovaComponent<P>:

import type { NovaComponent } from '@niscorp/nova/adapters/react';
import { z } from 'zod';

const MyButtonPropsSchema = z.object({
  label: z.string().optional().describe('Button label.'),
  variant: z.enum(['primary', 'secondary']).optional().describe('Visual variant.'),
}).strict();

type MyButtonProps = z.infer<typeof MyButtonPropsSchema>;

export const MyButton: NovaComponent<MyButtonProps> = ({
  label,
  variant,
  novaRef,
  novaModel,
  children,
}) => {
  // Component implementation...
  return <button>{label ?? children}</button>;
};

// Static meta — picked up by registerAll() automatically.
MyButton.meta = {
  description: 'A clickable button.',
  propsSchema: MyButtonPropsSchema,
};

Framework-injected props

The renderer adds these props to every component automatically (in addition to whatever props the layout supplied):

  • children?: ReactNode — child slot
  • novaRef?: string — the layout node's ref field, if set. Use this when dispatching events.
  • novaModel?: { ref: string; path: string } — present when the layout used model: on this node. Indicates the component is two-way bound. Read it to know whether to dispatch ui:model events on change.

Dispatching events

Components emit events back to the shell via useNovaDispatch:

const dispatch = useNovaDispatch();

// On click
<button onClick={() => novaRef && dispatch({ type: 'ui:click', ref: novaRef })}>...</button>

// On input change (two-way binding)
<input
  value={value}
  onChange={(e) => {
    if (novaModel) {
      dispatch({ type: 'ui:model', ref: novaModel.ref, payload: e.target.value });
    }
  }}
/>

The shell routes these to triggers with matching event: and ref: fields. If your component is model:-bound, the runtime auto-installs a listener that converts ui:model events into set mutations on the bound path — no trigger needed.

Registering

import { createComponentRegistry } from '@niscorp/nova';
import { MyButton } from './my-button';

const registry = createComponentRegistry();
registry.register('MyButton', MyButton);

// Or batch with the static meta picked up automatically:
registry.registerAll({ MyButton });

The default component set

@niscorp/nova/adapters/react/components ships nine components and a registration helper:

import { registerNovaReactComponents } from '@niscorp/nova/adapters/react/components';

const registry = createComponentRegistry();
registerNovaReactComponents(registry);
// Now Stack, Text, Input, Button, Box, Panel, JsonTree, CanvasSlot, ActionSlot are all registered.

Input is remote-safe: while focused it holds a local draft the incoming tree never overwrites, and a debounce prop (ms) coalesces ui:model dispatches, flushing on blur. See ADAPTER.md §6.

CanvasSlot and ActionSlot are shell-aware: they pull state from the React shell context and must only be used inside a <NovaShellProvider> (they are what shell/canvas layouts use to paint their regions).

You can mix the default set with your own:

const registry = createComponentRegistry();
registerNovaReactComponents(registry);
registry.register('MyButton', MyButton);

The slotWrapper seam

slotWrapper is the one pluggable point for wrapping an action instance's content as it mounts and unmounts. It's how you add animation, an auth / feature gate, logging, or an error boundary to a nova UI — without nova owning any of that logic.

Pass it to <Nova.Shell> (or to NovaShellProvider / NovaRenderProvider directly):

<Nova.Shell shell={shell} slotWrapper={MySlotWrapper} />

Nova renders every action instance's content through it, at the ActionSlot seam — the single place an instance's content appears and disappears. (ActionSlot is what a canvas's action layout uses to render its active instance; it's how the shell paints each canvas.) CanvasSlot is a router, not a content seam, so it is not wrapped — there is exactly one place a slotWrapper lives.

The contract

import type { SlotWrapper } from '@niscorp/nova/adapters/react';

const MySlotWrapper: SlotWrapper = ({ canvasId, instanceId, action, children }) => {
  // Decide what to render for this slot.
  return <>{children}</>;
};

Nova hands the wrapper identity, never live state:

  • canvasId — which canvas the instance lives in. Route by region: "animate detail", "gate admin".
  • action — the full ActionDefinition. Route by action: gate on action.id, read action.name, etc.
  • instanceId — the instance id. Use it as a React key for presence/animation.
  • children — the rendered content, or null.

All three identity fields are undefined while a slot is empty or exiting (the instance is gone). A presence-managing wrapper captured them when the content was present.

Identity, not state. The wrapper deliberately does not receive the action's live data — that would turn a presence/gate seam into a reactive render path. A wrapper that needs data reads it with a hook itself.

With no slotWrapper, rendering is unchanged — the default is a transparent passthrough.

What nova owns here: nothing but the seam

No animation logic, no timing, no animationend, no transition names, and no layout/JSON changes. The animation library (framer-motion, react-transition-group, plain CSS), the durations, the easing, the gate policy — all live in your wrapper, app-side. Swap framer for CSS and nova doesn't change. Policy is keyed off canvasId / action.id in your code, never on the ActionDefinition schema (it's .strict()) or in the layout an author — or a model — writes.

Example: animate one region

const AnimatedSlot: SlotWrapper = ({ canvasId, instanceId, children }) => {
  if (canvasId !== 'main') return <>{children}</>;       // only the main region animates
  return <div key={instanceId} className="slide-in">{children}</div>; // fresh key replays the enter
};
@keyframes slide-in { from { opacity: 0; transform: translateY(10px) } }
.slide-in { animation: slide-in 240ms ease-out }

That's a CSS enter. A full exit (animate out on close) is the same seam plus a presence library: nova renders the wrapper persistently (children becomes null while the instance is exiting), so an AnimatePresence / TransitionGroup inside your wrapper can hold the leaving content and animate it out on its own clock — nova never waits and never signals completion.

Example: an auth / feature gate

const AuthGate: SlotWrapper = ({ action, children }) => {
  const authed = useContext(AuthContext);                  // your app state, not nova data
  if (action?.id.startsWith('secret') && !authed) {
    return <Locked name={action.name} />;                  // a fallback instead of the content
  }
  return <>{children}</>;
};

The same seam, used as a gate: the wrapper decides whether to render the content or a fallback, keyed off the ActionDefinition. A generated layout that invents a brand-new canvas flows through the same wrapper and hits its default branch automatically — there is nothing to register per slot.

useSlotWrapper()

Reads the active slotWrapper from render context (or undefined). You rarely call it directly — ActionSlot does — but it's there if you build your own slot rendering. Outside a provider it returns undefined; it does not throw.

Both examples above ship as runnable demos in the showroom under Slot wrappers.


Error handling

The renderer can produce error nodes when in lax mode (the default). The React adapter renders them via the ErrorMarker component:

import { ErrorMarker } from '@niscorp/nova/adapters/react';

You don't usually need to use this directly — <RenderTree> handles error nodes internally. The output is a <span data-nova-error="CODE">...</span> element you can style with CSS.

For React-level errors (a buggy component throwing during render), use <NovaErrorBoundary>:

import { NovaErrorBoundary } from '@niscorp/nova/adapters/react';

<NovaErrorBoundary fallback={(error) => <div>Something broke: {error.message}</div>}>
  <CanvasView canvasId="main" />
</NovaErrorBoundary>

fallback is a function of the caught error; omitted, the boundary renders the error's message in a <div data-nova-error-boundary role="alert">. onError(error, info) reports the catch.

This is a real React class component (the only class in the package outside the error classes in shared/errors.ts — React still requires a class for an error boundary).


React compatibility

  • Version — React 19 (react peer ^19.2.4).
  • Strict mode — fully supported. The hooks use useSyncExternalStore and the render-tree hook caches its snapshot via useRef, so dev-mode double-mounting doesn't break anything.
  • Concurrent rendering — fully supported. useSyncExternalStore is tearing-safe by design.
  • Suspense — not used as a loading model. Loading state is explicit data on the action ({ loading: true } as a regular field). A consumer can wrap nova components in <Suspense> but it never activates because nova never throws promises during render.
  • SSR — supported. The shell-backed hooks and <NovaShell> draw under react-dom/server (each hook hands useSyncExternalStore the shell's own value as its server snapshot), and hydrateRoot adopts that markup over a second boot of the same shell. Draw the WHOLE first screen on both sides: await shellSettled(shell) before renderToString, and again in the page before hydrateRoot — see below. A served tree needs no hooks at all — NovaRenderProvider + RenderTree over values (a frame and per-canvas trees) is store-free, and is the path @niscorp/moss/terminal/react/server draws through.
  • React Server Components — not tested. The hooks are client-only.

A shell drawn ahead of time

An app whose shell lives in the page can still arrive as markup. The same boot runs twice — once where there is no browser, once in the page:

// at build (or per request) — no browser
const shell = boot();
await shellSettled(shell);                       // the first screen is whole
const html = renderToString(<NovaShell shell={shell} />);

// in the page — #root already holds `html`
const shell = boot();
await shellSettled(shell);                       // reach the same screen first
hydrateRoot(root, <NovaShell shell={shell} />);  // the same elements, now alive

Three things make the two draws agree, and a build should check all of them (nisc build does): the boot is deterministic (pass instanceIdFn if an instance id reaches the markup; no Math.random(), no wall clock in a transform), the screen is settled on both sides, and whatever the mount hooks read answers the same in the page as it did at build. A read that answers differently — a date that has moved on — is drawn again by the page's shell, and React says so in the console.


Common patterns

Render the active action of a canvas

const ActiveCanvas = ({ canvasId }: { canvasId: string }) => {
  const canvas = useCanvas(canvasId);
  const tree = useRenderTree(canvas.active?.id ?? '');
  return <RenderTree nodes={tree} />;
};

Render multiple canvases side by side

const App = () => (
  <NovaShellProvider shell={shell} registry={registry}>
    <div style={{ display: 'flex' }}>
      <ActiveCanvas canvasId="nav" />
      <ActiveCanvas canvasId="content" />
    </div>
  </NovaShellProvider>
);

Read live data alongside the canvas

const Header = ({ canvasId }: { canvasId: string }) => {
  const canvas = useCanvas(canvasId);
  const data = useActionData(canvas.active?.id ?? '');
  return <h1>{data?.['title'] as string ?? 'Untitled'}</h1>;
};

Push from outside nova (a router event, a websocket message, etc.)

const SomeReactComponent = () => {
  const shell = useShell();
  return <button onClick={() => shell.push('main', 'login')}>Log in</button>;
};

Quick reference

// Providers
<NovaShellProvider shell registry? slotWrapper?>...</NovaShellProvider>
<NovaRenderProvider registry dispatch? publish? slotWrapper? fallback? textWrapper? errorMarker?>...</NovaRenderProvider>

// Mountable surfaces
<Nova.Layout layout data? components? registry? store? builtins? strict? onError? phrases? phraseKeys? onPhraseMiss? />
<Nova.Shell shell registry? slotWrapper? builtins? />
<Nova.Canvas id shell? registry? builtins? />

// Hooks
useShell()                    // → Shell (throws outside provider)
useShellState()               // → StateSnapshot (subscribes)
useCanvas(canvasId)           // → CanvasState (subscribes to canvas)
useActionData(instanceId)     // → data | undefined
useActionStatus(instanceId)   // → ActionStatus | undefined
useRenderTree(instanceId)     // → RenderNode[]
useShellRenderTree()          // → RenderNode[] (the canvasLayout)
useCanvasRenderTree(canvasId) // → RenderNode[] (one canvas's actionLayout)
useNovaDispatch()             // → (event) => void
useNovaPublish()              // → (channel, payload?) => void
useNovaRegistry()             // → ComponentRegistry
useSlotWrapper()              // → SlotWrapper | undefined

// Render
<RenderTree nodes={...} />
<RenderNodeView node={...} />

// Slot wrapper (animation, gates, logging, error boundaries)
type SlotWrapper = FC<{
  canvasId?: string;          // undefined while the slot is empty/exiting
  instanceId?: string;
  action?: ActionDefinition;
  children?: ReactNode;
}>;

// Errors
<ErrorMarker code message />
<NovaErrorBoundary fallback? onError?>...</NovaErrorBoundary>   // fallback: (error) => ReactNode

// Component contract
type NovaComponent<P> = React.ComponentType<NovaComponentProps & P> & { meta?: ComponentMeta };
type NovaComponentProps = {
  children?: ReactNode;
  novaRef?: string;
  novaModel?: { ref: string; path: string };
};

The Vue adapter

@niscorp/nova/adapters/vue (peer vue ^3.5, optional) is the same surface in Vue 3 render functions — no SFC compiler. The names match this guide: NovaRenderProvider, NovaShellProvider, NovaShell, NovaCanvas, RenderTree, RenderNodeView, ErrorMarker, NovaErrorBoundary, and every hook above as a composable. The differences:

  • The shell-backed composables return a Readonly<Ref<…>> and take a ref or getter for their id (useCanvas(() => props.id)).
  • The render context travels by provide/inject (NovaRenderKey, NovaShellKey); children reach a component as its default slot.
  • A component receives the node's props, novaRef and novaModel as Vue props, so one that declares props lists each it reads (the kit's Button lists novaRef); a functional component that declares none receives them all.
  • A slot wrapper is a Vue component: canvasId, instanceId and action arrive as props, the content as its default slot.
  • There is no Nova namespace and no NovaLayout; NovaRenderProvider takes fallback but not textWrapper / errorMarker.
  • NovaErrorBoundary's onError receives Vue's info string.
  • The kit is registerNovaVueComponents(registry) from @niscorp/nova/adapters/vue/components: the same nine names over the same props schemas.

For the layout language registered components consume, see LAYOUT_DOCS.md. For the action machinery the shell drives, see ACTION_DOCS.md and SHELL_DOCS.md.