# Chapter 4 — UI: Rendering and Binding A pattern's `[UI]` is a JSX tree. It looks like React, but the semantics follow from Chapter 3's rule — the body runs once — so the JSX you write is not "what to render now" but "how the rendering depends on state, forever." This chapter covers the component library, two-way binding, events, and the two rendering structures (conditionals and lists) where the graph-not-render-function distinction actually bites. ## The component library: `cf-*` Patterns build UI from a library of web components (Lit elements, package `packages/ui`), all prefixed `cf-`. The component source of truth is `packages/ui/src/v2/components/`; the type-checked story catalog (`packages/patterns/catalog/catalog.tsx`, with live usage examples in `packages/patterns/catalog/stories/`) covers most of them and is the best place to see real usage. The main families: - **Inputs:** `cf-button`, `cf-input`, `cf-textarea`, `cf-checkbox`, `cf-select`, `cf-slider`, `cf-switch`, `cf-toggle`, `cf-radio`, `cf-autocomplete`, `cf-tags`, `cf-picker`, `cf-calendar`, `cf-message-input`, `cf-prompt-input`, `cf-image-input`, `cf-code-editor` - **Layout:** `cf-screen`, `cf-card`, `cf-vstack`/`cf-hstack`, `cf-vgroup`/`cf-hgroup`, `cf-vscroll`/`cf-hscroll`, `cf-grid`, `cf-list-item`, `cf-modal`, `cf-tab-bar`, `cf-toolbar` - **Display:** `cf-heading`, `cf-text`, `cf-label`, `cf-chip`, `cf-badge`, `cf-markdown`, `cf-svg`, `cf-separator`, `cf-kbd`, `cf-copy-button` - **Feedback:** `cf-progress`, `cf-loader`, `cf-skeleton`, `cf-alert`, `cf-toast` - **Data viz:** `cf-chart` (with `cf-line-mark`, `cf-bar-mark`, ...), `cf-map` - **Composition:** `cf-render` (render another piece's UI from a cell — bind with `$cell`), `cf-cell-context` Why a custom library instead of plain HTML? The next section is the answer. ## Two-way binding: the `$` prefix The defining feature of the component set is that form components bind *directly to cells*. Prefix a property with `$` and the component both reads and writes the cell — no handler, no state hook: ```tsx // Shown as JSX element children. // text input ⟷ title cell // checkbox ⟷ item.done ... ``` Combined with `.key()` from Chapter 2, an entire edit form is bindings: ```tsx // Shown as JSX element children. ``` That's a durable, multi-user, conflict-managed form in two lines — each keystroke is a cell write, which is a transaction, which syncs (Chapter 9). Two rules: - **Native HTML inputs are one-way only.** `` displays but never writes back. Always use the `cf-*` form components for editable state. - **Don't echo the binding.** Adding an `oncf-change` handler that writes the same value back into the `$value` cell creates feedback; the binding *is* the value path. Use change handlers only for dependent side effects. ## Events Three conventions, all about exact spelling: - `onClick` (camelCase) on `cf-button`. It accepts a `Stream` directly (`onClick={decrement}`) or a closure (`onClick={() => stream.send(x)}`). - Component-specific events use `oncf-` + kebab-case: `oncf-send` (`cf-message-input`, payload `e.detail.message`), `oncf-change`, `oncf-input`, `oncf-remove`, `oncf-keydown`. - Component *properties* are camelCase, exactly as typed in the catalog: `` works; `allow-custom` silently does not. The single most common UI bug in this system is invoking instead of passing: ```tsx // Shown for illustration only. // ❌ fires during render! selectItem.send(index)}> // ✅ ``` The wrong version runs `.send()` while the graph is being built/rendered — a write during rendering — and produces the infamous `non-idempotent raw:map` / "Too many iterations" errors (`docs/development/debugging/gotchas/immediate-event-invocation.md`). ## Conditional rendering Write plain ternaries; the compiler lowers them into reactive nodes: ```tsx // Shown inside a pattern body. {show ?
Content
: null} {score >= 90 ? "A" : score >= 80 ? "B" : "C"} ``` This is the place to understand *why* the obvious alternative is wrong. You might think to gate JSX with `computed()`: ```tsx // Shown inside a pattern body. // ❌ WRONG — showForm is a Writable object inside the closure {computed(() => { if (!adminMode.get()) return null; return <>{showForm ?
This ALWAYS renders!
: null}; })} ``` Inside a `computed()` closure you are in plain JavaScript: `showForm` there is the *cell object*, and objects are always truthy, so the inner ternary never works. In JSX proper, the compiler rewrites the ternary so the condition is the cell's *value*. Hence the rule: **ternaries in JSX, `computed()` for data, never `computed()` around JSX.** There is also an explicit `ifElse(cond, thenVNode, elseVNode)` operator — you'll see it in repo patterns — but you rarely need to write it; the ternary lowers to it. One known gotcha: feeding `ifElse` a cell taken directly off a *composed sub-pattern* can hang the piece; bridge it through a local `computed(() => sub.flag)` first (`gotchas/ifelse-composed-pattern-cells.md`). ## List rendering `.map()` on a reactive array (input or `computed()` result) is reactive list rendering — and unlike React, no `key` prop is needed; items have identity as cells: ```tsx // Shown inside a pattern body. const activeItems = computed(() => items.get().filter((i) => !i.done)); const itemCards = activeItems.map((item: TodoItem) => ( )); ``` For per-item behavior, bind a module-scope `handler()` inside the map (`onClick={deleteItem({ index, items })}`) or use a closure that sends. Gotchas that matter in practice: - Avoid nested cell-reads inside nested maps — pre-compute a top-level `computed()` instead (`gotchas/closure-capture-in-nested-map.md`). - Reading a `PerSession` cell inside a `computed()` that's nested in a `.map()` over a computed list is silently blocked; read it once at top level and close over the value (`gotchas/persession-read-in-mapped-computed.md`). - Scoped cells are `undefined` until first sync — guard render-path reads with `?? []` (`gotchas/scoped-cell-pitfalls.md`). ## A complete example: the todo list `packages/patterns/todo-list/todo-list.tsx` exercises everything in this chapter. Excerpted (the full file also defines `TodoItemPiece` and a completed-item variant as sub-patterns — Chapter 5 covers composition): ```tsx // Shown for illustration only. export default pattern(({ items }) => { const addItem = action(({ title }: { title: string }) => { const trimmed = title.trim(); if (trimmed) items.push({ title: trimmed, done: false }); }); const removeItem = action(({ item }: { item: TodoItem }) => { items.remove(item); }); const activeItems = computed(() => items.get().filter((i) => !i.done)); const completedItems = computed(() => items.get().filter((i) => i.done)); const hasCompleted = computed(() => completedItems.length > 0); const itemCards = activeItems.map((item: TodoItem) => ( )); return { [NAME]: computed(() => `Todo List (${items.get().length})`), [UI]: ( {itemCards} {ifElse(hasCompleted,
{/* completed section */}
, null)}
{ const title = e.detail?.message?.trim(); if (title) addItem.send({ title }); }} />
), items, addItem, removeItem, // (the real file returns a few more fields: itemCount, summary, ...) }; }); ``` And the sub-pattern rendering one item is two bindings and a button: ```tsx // Shown as JSX element children. removeItem.send({ item })}>x ``` Trace the checkbox once more, now with full vocabulary: `$checked` writes `item.done` → the `activeItems` and `completedItems` computeds re-run → the two `.map()`s update → the card moves sections — locally at once, and on every other subscribed client after the commit round-trips. --- **Next:** [Chapter 5 — Composition, pieces, and capabilities](05-composition-and-pieces.md). **Under the hood:** how JSX becomes graph nodes and why ternaries lower — [Chapter 7](07-compilation.md); how the shell turns VNodes into live DOM — [Chapter 11](11-deployed-system.md).