Skip to Content
ReactComponent implementation

Component implementation

A component is a pair: a definition the model reads and an implementation React runs. The implementation decides what renders: a component from your design system, or plain markup.

src/uicast-catalog/badge/impl.tsx
import { createComponentImplementation } from "@uicast/react"; import { Badge } from "./badge"; import { BadgeDef } from "./def"; export const BadgeImpl = createComponentImplementation({ def: BadgeDef, render: ({ text, children, variant }) => ( <Badge variant={variant}>{children ?? text}</Badge> ), });

createComponentImplementation returns the implementation to pass to <RendererProvider>. An optional third field, skeleton, draws the component while it waits for data or children.

The render function

render gets two arguments. The first is one object, typed from the def, with every prop, every callback and children side by side ({ text, variant, onClick, children }):

  • Each prop, already resolved: the engine evaluates the entry’s props and parses the result through the def’s schema, so every .default() is applied.

    { "key": "total", "component": "Typography", "props": { "expr": "({ text: 'Open: ' + scopes.root.open })" } } // render receives { text: "Open: 3" } // …and is called again with "Open: 4" the moment something writes scopes.root.open
  • Each callback, a (payload) => Promise<void> under its key in the def’s callbacks, present even if the entry wired none. Calling one parses the payload through its schema and runs the entry’s callback steps with it as evt.

  • children, the rendered child elements, present only when the entry has a children array. It is never a prop (a def declaring one throws).

The second is the render context:

  • entry, the entry being rendered, unevaluated.
  • loading, the entry’s loading expression, evaluated. What to show while it is true is up to the implementation, such as a busy state.
  • scopes, the scopes the entry’s expressions read: scopes.root and, inside a list, the item’s two scopes. It is for debugging; state changes go through callbacks.
src/uicast-catalog/search-input/impl.tsx
export const SearchInputImpl = createComponentImplementation({ def: SearchInputDef, render: ({ value, onChange, onClear, onKeyDown }) => ( <div> <Input value={String(value ?? "")} onChange={(e) => onChange({ value: e.target.value })} onKeyDown={(e) => onKeyDown({ key: e.key, repeat: e.repeat })} /> <button onClick={() => onClear()}>clear</button> </div> ), });

Build each payload from the React event by hand, with exactly the fields its schema declares.

Skeleton

An optional skeleton draws a component while something is missing. It gets four props: reason, entry, knownProps and children. reason says what is missing:

  • "streaming": a child entry has not arrived yet. The parent’s skeleton fills the slot, with the parent’s entry, since the child’s component is not known yet. It gets no children.
  • "seeding": the element’s own seed is waiting on an async host function. The element and everything under it draw as skeletons, as DocumentSkeleton draws them: each skeleton gets children (its children’s skeletons, or null) and draws its own tag, a list draws three items, and an element under it with hidden is left out. While init loads, the whole document draws this way.

knownProps holds the props that need no evaluation, a literal or none, checked by the definition with its defaults filled in, as render’s are. It is absent when the props come from an expression, and in a child’s slot.

src/uicast-catalog/card/impl.tsx
export const CardImpl = createComponentImplementation({ def: CardDef, render: ({ title, children }) => <Card title={title}>{children}</Card>, // With `children`, draw the Card itself; without, fill a slot inside a real one. skeleton: ({ knownProps, children }) => children === undefined ? <Skeleton className="h-40" /> : <Card title={knownProps?.title}>{children}</Card>, });
// while getSales() is in flight, the panel draws as CardImpl's skeleton, // titled "Sales" from its literal props, with BarChartImpl's inside { "key": "panel", "component": "Card", "props": { "literal": { "title": "Sales" } }, "seed": [{ "set": "scopes.root.sales", "expr": "getSales()" }], "children": ["chart"] } // once the seed settles, the Card renders; a "chart" line // that hasn't streamed in yet leaves a slot, filled by CardImpl's skeleton // without children, since the gap is the parent's { "key": "chart", "component": "BarChart", "props": { "expr": "({ data: scopes.root.sales, xKey: 'month', yKeys: ['total'] })" } }

With no skeleton, an element with children draws theirs, and anything else draws fallbackComponents.defaultSkeleton, or nothing.

Registration

Pass every implementation to <RendererProvider implementations={...}>; the definitions go to the prompt. Adding a component means updating both.

Props the document controls

A model wrote every prop your implementation gets. The evaluator checks expressions, not what your JSX does with their results. Follow three rules:

  • Put URLs only in URL props. Declare the prop as a URL in the definition (z.url()), and urlPolicy checks it before render runs. A plain string prop in src, href or poster is checked by nothing.
  • Never put document text where it can run. dangerouslySetInnerHTML, <style> contents, srcDoc and on* attributes can run script. A CSS string can leak data through url(...). A style object with fixed keys is fine: style={{ width: props.width }}. A whole CSS or HTML string from the document is not.
  • Send plain data from callbacks, never the event. Everything that goes into a scope must be JSON-shaped.

The reference catalog follows all three. The security model holds only if your implementations do too.

Last updated on