# 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

```bash
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

```tsx
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.

```tsx
<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.

```tsx
<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`).

```tsx
<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>`.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
const data = useActionData(activeId);
```

### `useActionStatus(instanceId)`

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

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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

```tsx
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>`:

```tsx
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`:

```tsx
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

```ts
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:

```ts
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:

```ts
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):

```tsx
<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

```tsx
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

```tsx
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
};
```

```css
@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

```tsx
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:

```tsx
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>`:

```tsx
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:

```tsx
// 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

```tsx
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

```tsx
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

```tsx
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.)

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

---

## Quick reference

```tsx
// 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`.
