# 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).