Components and Render Programs
js/ui.md
Fino has one component model. A component is a synchronous function from props to a tree, and it does not know or care where it runs, what its output becomes, or how long it lives. Those are decisions the surrounding render program makes, and the same component serves a terminal app, a static HTML build, a server-driven page, and a browser.
/** @jsxImportSource fino:ui */
export default function Greeting(props: { name: string }) {
return <p>Hello {props.name}</p>;
}
Two axes
A render program is a Sink plus a lifetime.
A sink decides what a committed tree becomes. htmlSink() serializes to
HTML text, portableSink() converts to transferable JSON, frameSink() in
fino:tty/tui paints a terminal frame, and hostSink() wraps a HostAdapter
so a mutable node graph like the DOM participates on the same footing.
The lifetime is either one pass or many. renderStatic() renders once,
commits, and disposes. createRoot() keeps rendering: every signal read during
a pass becomes a dependency, so a later write re-renders and commits again.
import { createRoot, renderStatic } from 'fino:ui';
import { htmlSink } from 'fino:ui/html';
const once = renderStatic(App, htmlSink());
const live = createRoot(App, htmlSink());
live.subscribe((html) => publish(html));
live.dispose();
Those two choices are independent. Any sink works with either lifetime, which is why adding a host does not mean reimplementing reactivity, and why making something live does not mean rewriting its output path.
Components run when a target lowers them
h() does not call a component. It stores the function as the node's type,
and the component runs later, during lowerTree(tree, target) — which is what
a sink does on commit. The delay is the point: between building the tree and
running a component there is a moment where the render target is known, and
that is the moment a target gets to substitute its own version of that
component.
import { mapRenderTargetLowering } from 'fino:ui';
using checkbox = mapRenderTargetLowering(Checkbox, 'tui', TuiCheckbox);
using article = mapRenderTargetLowering('article', 'tui', TuiArticle);
Registration is deliberately not the component author's privilege. The map is keyed on the function itself, so a render target can lower components it did not write and cannot modify — which is how a target is added without editing the components it renders. A string key matches a host element name instead, so a target can catch elements generically rather than specialising every component that emits one. The last active registration for a pair wins, so an application can override either. Each registration is disposable: removing a temporary override restores the previous mapping, including when independent registrations are disposed out of order.
Lowering repeats until nothing is left but the target's own primitives, which
each target declares with defineRenderTarget(). A component that only
composes other components therefore needs no registration at all and runs
anywhere; only the leaves, where meaning becomes real output, need a target to
say anything. A node that reaches a target with no lowering and no place in its
floor raises RenderTargetError naming both, rather than rendering nothing.
Calling a component directly (Greeting({ name: 'fino' })) still works and
simply bypasses the registry.
State is never a component's job
Components do no asynchronous work. There is no await in a render pass and no
suspend protocol, because state that has not arrived yet is not a rendering
problem — it is a signal that has not been set yet.
import { createSignal } from 'fino:signals';
const rows = createSignal<Row[]>([]);
void loadRows().then((loaded) => rows.set(loaded));
function Table() {
return h('table', null, rows.get().map(renderRow));
}
Under createRoot() that component renders empty, then re-renders when the load
resolves. Under renderStatic() it renders whatever rows holds right now, and
a signal write during the pass throws StaticRenderError — a one-shot render
has nowhere to publish a revision, so output that disagrees with its own state
is reported rather than shipped.
Rendering somewhere else
fino:ui/realm is the third option, and it is how asynchronous data becomes
static output without any component changing.
A component rendered in a child realm republishes its tree on every revision. The realm's event loop decides when there is nothing left to do, and draining is completion: the last tree published before the child exits is the answer.
import { renderRealm } from 'fino:ui/realm';
import { renderToHtml } from 'fino:ui/html';
const tree = await renderRealm('./report.tsx', { props: { period: '2026-Q1' } });
await write('report.html', renderToHtml(tree));
Realms are held open by pending work rather than by an open port, so a rendering child cannot idle waiting to be asked for more. Render a batch in one run instead — one isolate, shared data sent once, a tree per item:
import { renderRealmAll } from 'fino:ui/realm';
const trees = await renderRealmAll('./theme.tsx', {
shared: { site },
items: pages.map((page) => ({ page })),
});
Portability is the contract
A tree that leaves the isolate that built it must be data. fino:ui/portable
defines that subset and enforces it at the boundary: functions, class instances,
and cycles are rejected with the offending property path named, rather than
failing later as an opaque clone error.
import { toPortable } from 'fino:ui/portable';
toPortable(h('div', { onClick: () => {} }));
// PortableValueError: Portable UI values must be JSON data at tree.props.onClick
undefined follows JSON rules — an undefined prop is omitted, since a prop set
to undefined and one never set are the same absent prop.
Pre-rendered markup is an ordinary node rather than a special case, so it crosses boundaries like anything else and each host sets its own policy:
import { rawHtml } from 'fino:ui/html';
const node = rawHtml('<span>ok</span>'); // { type: 'ui:raw', props: { html } }
This is what makes a component's location a deployment decision. A theme, a plugin, or a page template that stays inside the portable contract runs in-process, in a sandboxed realm, or in a browser without changing.
Component previews
fino:ui/components provides structural primitives and
semantic components whose trees lower to retained terminal cells or native HTML.
Each component family owns preview descriptors beside its implementation, and
fino:ui/preview composes those descriptors into both
runners. The catalog therefore exercises production lowerings instead of
maintaining a preview-only rendering layer.
Choosing
| You want | Use |
|---|---|
| A page, a file, a frame — once | renderStatic() + a sink |
| Output that tracks state | createRoot() + a sink |
| Untrusted or async-loading components | renderRealm() |
| Many pages from one component | renderRealmAll() |
| A mutable host (DOM, terminal cells) | hostSink() |
See Server-Driven Web UI for the hypermedia application layer built on these pieces.