Lists, Conditionals, and Components
Use dynamic structure when elements need to appear, disappear, or move. This
page covers conditional regions, keyed lists, and reusable components with local
state. The snippets assume the usual Ui, Html, Signal, and Elem imports;
list examples also use import pf.Rows.
Each mechanism establishes a scope that owns its mounted state, signals, DOM, and active effects. Removing the scope releases those resources and cancels its active work. State owned by an ancestor can outlive the removed region.
Conditionals
Ui.when takes a Signal(Bool) and two functions that take no arguments:
Ui.when(
is_open,
|| Html.paragraph("Open"),
|| Html.paragraph("Closed"),
)
Each arm is its own scope. When the condition flips, the losing arm is disposed and the winning arm mounted. The structural replacement is limited to that branch; other bindings can still update if they also depend on the changed state.
Prefer signal-backed attributes over structure
If the same elements should remain mounted, bind their changing values directly:
message = is_error.map(|failed| if failed { "Failed" } else { "Ready" })
classes = is_error.map(|failed| if failed { "text-red-700" } else { "text-zinc-600" })
Html.div([Html.class_attr_s(classes)], [Html.text_s(message)])
This changes the text and class without replacing the element. Use Ui.when
when the contents need a different lifetime, such as a form that should reset
when closed. Hiding an element with a class or hidden attribute keeps its
state and effects active.
Multi-way branching
Use Ui.switch to select structure by a typed case:
Ui.switch(
page_kind,
|kind| match kind {
Home => home_page()
Login => login_page()
NotFound => not_found_page()
},
)
Only the live case’s builder runs. An unequal case disposes the previous branch and mounts a new one. Select a page kind when changing route parameters should preserve the page’s local state, and pass the parameters as signals to that page. Selecting the entire route instead resets the branch whenever any route field changes according to its equality comparison.
Keyed lists
Ui.each renders a keyed Rows collection:
book_list_view : List(Book) -> Try(Elem, Rows.Error)
book_list_view = |book_list| {
books = Rows.from_list(book_list, |book| book.id)?
Ok(Ui.each(Signal.const(books), book_row))
}
Ui.each has two arguments:
- a
Signal(Rows(item)), Ui.Row(item) -> Elem, the row renderer.
The Rows value owns the item -> Str key projection supplied to
Rows.from_list or Rows.empty. It evaluates and validates that projection
when a generation is constructed, so rendering does not recompute keys.
Handle the Try where the collection enters your UI. The example above assumes
a Book record with id : Str and title : Str. A changing list uses a
Signal.Signal(Rows.Rows(Book)) in place of Signal.const(books).
The row renderer receives a live row rather than an item snapshot. When an item changes without changing its key, its row signal updates and the existing row scope remains mounted.
book_row : Ui.Row(Book) -> Elem
book_row = |row| {
title = row.map(|value| value.title)
Html.div_c("flex gap-3", [Html.text_s(title)])
}
Keys
The key is the row’s identity. Get it from the data — a database id, slug, or client-generated id.
Choose keys that stay with the item when its position changes. If you derive keys from list positions, state stays attached to those positions and can appear on a different item after insertion or sorting.
Keys must be unique within one collection. Constructing or editing Rows with
duplicate keys returns Rows.Error.DuplicateKey(key). Handle that error where
you build the collection; rendering does not merge the duplicate rows.
Rows expose identity and a live source
A row renderer receives Ui.Row(item), not an item snapshot. row.key() is
the exact stable UTF-8 identity, row.signal() is the live item source, and
row.map(project) builds an ordinary equality-pruned graph projection. The
row source is generation-checked and remains stable while its keyed scope is
live, including across reorder and replacement collection generations.
Repeated controls also need a way to be distinguished. If every row has a
checkbox named "Read", a label locator matches several controls. Give the
control a meaningful accessible name and use a stable key for a test id:
Html.checkbox_attrs("Read", read, [Html.test_id("book-${row.key()}")], msg)
Derive links, labels, and other changing values from row.signal() or row.map(...). The key should
contain only durable identity; changing it retires the old row scope and creates
a new one by design.
Row-local state
Declare Ui.state inside the row renderer when each row needs its own value.
Here Line is a record with a name : Str field; each row starts with quantity 1:
line_row : Ui.Row(Line) -> Elem
line_row = |row| {
sku = row.key()
line = row.signal()
Ui.state(
1.U64,
|qty| {
quantity : Signal.Signal(U64)
quantity = qty.signal()
label = { line: line, qty: quantity }.Signal.map(
|v| "${v.line.name} x${v.qty.to_str()}",
)
Html.div_c(
"flex gap-2",
[
Html.paragraph_s_attrs(label, [Html.test_id("quantity-${sku}")]),
Html.button_attrs("Add one", [Html.test_id("add-${sku}")], qty.on_unit(|n| n + 1)),
],
)
},
)
}
This state follows a surviving key through reordering and removal of other rows. Filtering this row out disposes its state and effects; inserting its key again creates a new lifetime. State that must survive filtering belongs in an owner outside the rendered rows. Reordering preserves state, which a spec can assert:
(click (test-id "add-a1"))
(click (test-id "add-a1"))
(expect-text (test-id "quantity-a1") "Keyboard x3")
(mark-metrics)
(click (role button :name "Reverse"))
(expect-text (test-id "quantity-a1") "Keyboard x3")
(expect-metric-delta rows_created 0)
(expect-metric-delta rows_removed 0)
After reversing the list the quantity is still 3, and the host created and destroyed zero rows — it moved the existing DOM nodes.
State declared outside Ui.each belongs to the surrounding scope and can be
shared by rows that reference it. When a row needs to change the list’s state — deleting an item, toggling a field on the shared
model — define the row renderer inside the outer Ui.state body so it closes
over the outer handle, as the tutorial
does.
Components
A component is just a function returning an Elem. If it needs its own state,
wrap it in Ui.component to give it a private identity scope:
counter : Str -> Elem
counter = |label|
Ui.component(
|| Ui.state(
0.U64,
|count| {
text = count.signal().map(|n| "${label}: ${n.to_str()}")
Html.div_c(
"flex gap-2",
[
Html.button("Increment ${label}", count.on_unit(|n| n + 1)),
Html.text_s(text),
],
)
},
),
)
counter("Left") and counter("Right") have separate scopes. Construction
order inside either component is local to it, so adding internal state does not
shift the caller’s other state declarations. The component itself still has a
construction site in its caller; the wrapper does not give it a stable list key.
Wrap reusable helpers that declare Ui.state, Ui.when, Ui.switch, or
Ui.each in Ui.component to keep their internal sites in a local scope.
Purely presentational helpers do not need the wrapper.
A component wrapper establishes ownership. It does not, by itself, rebuild the
component when an input changes. Use Ui.when or Ui.switch around a component
when a change should end its lifetime and mount a fresh instance.
Passing data across a component boundary
Choose inputs according to what changes together. For independent values, a named record of signals preserves separate dependencies:
article_card : { title : Signal.Signal(Str), author : Signal.Signal(Str), saved : Signal.Signal(Bool) } -> Elem
Use Signal.Signal(CardProps) when the props form one coherent value. Mapping
its fields inside the component still subscribes each projection to the whole
record; moving the projections does not make the inputs independent.
Components can also take static values, event handlers, and List(Elem)
children. State declared inside a child description belongs to the scope where
that child is mounted. References to a caller-owned signal keep the caller’s
ownership; passing a signal does not move its state into the component.
Where identity comes from
The runtime assigns structural identities by deterministic traversal of the returned description within each scope. A construction site is a declaration in that description, not a source line, DOM position, or component name. Conditional branches, switch cases, keyed rows, and components separate their internal declaration order from their siblings.
Use Ui.when, Ui.switch, or Ui.each for changing structure. Varying the
number of state declarations in an ordinary constructed child list can shift
later sites. Moving state across a scope boundary also changes who owns it.
A key preserves identity only while it survives at the same list site. Removing
and later reinserting it starts a new lifetime. Moving it between two different
Ui.each sites also creates a new lifetime at the destination. Keep state in an
ancestor, indexed by the domain key, if it must survive filtering, pagination,
or movement between lists.
Performance notes
Choose the primitive that describes the intended change:
- Bind changing text and attributes with
text_s,class_attr_s,attr_s, orbool_attr_swhen the elements should remain mounted. - Use
Ui.whenorUi.switcharound the region whose lifetime should change. - Use
Ui.eachwith stable item keys for a changing collection. - Declare row-local state inside the row renderer, and persistent state outside it.
- Derive computed values and use equality that preserves every observable change.
Keep independently changing panels on separate sources so unrelated updates do not recalculate their projections.
Rows.apply can carry a small edit set to Ui.each. The engine uses that delta
when the edit starts from the generation currently rendered at the site. A
replacement snapshot, or an edit from a different generation, requires comparing
the collection. Surviving keys still preserve their row scopes in either case.
The cost of constructing, filtering, or sorting your collection also counts;
preserving rows does not make those application calculations constant-time.
Measure the behavior your application depends on with native specs. For example,
(expect-metric-delta rows_created 0) checks that an interaction creates no rows. See
Testing.
Next
Effects, HTTP, and the Browser — talking to servers, timers, history, and storage.