Control flow
Conditionals and lists inside a view — the for and if keywords, the components they lower to, and why a key is required.
A view is built once, so if and for in ordinary Rust would run once too. The view grammar has
its own for and if, which rebuild what they hold when what they read changes. This page assumes
Views, Signals and
Components.
Conditionals
view! {
column {
if move || open.get() {
box(class = "panel") {"Contents"}
} else {
label(class = "hint") {"Nothing selected"}
}
}
}The condition must be written as a closure. move, | or || has to be the first token, or
it is a parse error on the condition itself:
if open.get() { … } // error: `if` takes a closure
if move || open.get() { … } // correctThe rule looks strict and is the point. A condition read once is a snapshot, and a branch built from a snapshot never changes again. Requiring the closure in the grammar means the non-reactive spelling cannot be written at all, instead of compiling and silently freezing.
What it costs
The condition is read inside one effect, and it is the answer that is compared, not the signals the answer was computed from.
if move || items.get().is_empty() { … } else { … }Adding a row to a list that already had rows changes items, re-runs the condition, gets false
again, and swaps nothing. The branch is rebuilt only when the boolean itself flips.
Both branches are built afresh when they are shown. A branch that is not showing holds no nodes, no signals and no timers. It is not hidden with CSS; it does not exist.
else is optional
if move || saving.get() {
label {"Saving…"}
}With no else, the other branch renders nothing. Writing else { } means the same thing, and says
so explicitly.
What the grammar refuses
| You write | Why |
|---|---|
if open.get() { … } | the condition must be a closure |
if let Some(x) = y { … } | pattern matching is not part of the view grammar |
if a { … } else if b { … } | write else { if move || b { … } else { … } } |
if move || a { } | an if needs a body |
if move || a (class = "x") { … } | control flow takes no attributes |
else if is refused rather than desugared because each arm is its own conditional: an outer arm
changing rebuilds the ones inside it, and the nesting says so.
For if let, compute the value outside the view, or use a braced child holding a match.
Lists
view! {
column(class = "list") {
for item in move || items.get(), key = |item: &Todo| item.id {
row(class = "todo") {
text {{item.label.clone()}}
}
}
}
}Three parts, in this order:
- The row name.
itemis bound inside the block. Exactly one name — no tuple destructuring and no_. Destructure inside the body if you need to. - The collection, after
in, written as a closure. This is the list's only reactive dependency. - The key, after a comma, as
key = <expression>. It is required.
Why the key is required
When the collection changes, the list compares the new keys with the old ones and works out what happened: which rows are new, which are gone, which moved. Rows that are still there are moved, not rebuilt — they keep their nodes, their signals and their scroll positions.
Without a key the list would have to fall back on position, and inserting one row at the top would rewrite every row below it.
The key needs Eq + Hash + Clone. Use whatever identifies a row for as long as it exists:
key = |item: &Todo| item.id // a stable id
key = |name: &String| name.clone() // the value itself, when it is the identity
key = |n: &usize| *nDo not key by index. An index is a position, not an identity, so keying by it gives you exactly the behaviour the key exists to avoid.
Rows own their own state
Each row gets its own scope. A signal created inside a row is disposed of when that row is removed.
This is why the common shape for a list is a collection of items where each item owns the signal for the part of it that changes:
#[derive(Clone, Debug)]
struct Todo {
id: u64,
label: String,
done: RwSignal<bool>,
}
view! {
for item in move || items.get(), key = |item: &Todo| item.id {
row(
class = "todo",
class:done = move || item.done.get(),
on:click = move |_| item.done.update(|done| *done = !*done)
) {
text {{item.label.clone()}}
}
}
}Ticking one item writes that item's done. Only that row's class binding reads it, so only that
row changes. The collection signal is untouched, so the list does not compare keys at all.
for has no else
An empty list renders nothing. To show something instead, wrap it:
if move || items.get().is_empty() {
label(class = "empty") {"Nothing to do yet."}
} else {
for item in move || items.get(), key = |item: &Todo| item.id {
row { text {{item.label.clone()}} }
}
}The components underneath
for and if are sugar. Each parses straight into a component call, and the two spellings
produce the same code:
| Keyword | Component |
|---|---|
for item in each, key = k { … } | For(each = each, key = k, let:item) { … } |
if when { … } | Show(when = when) { … } |
if when { … } else { … } | Show(when = when, fallback = move || view! { … }) { … } |
Both spellings are supported and neither is deprecated. Reach for the component form when the head is not a closure literal — most often when a condition already lives in a variable:
let chosen = move || selection.get().is_some();
view! {
Show(when = chosen) {
label {"picked"}
}
}when takes impl Fn() -> bool, so the variable has to hold a closure. A Signal<bool> is not one:
write move || flag.get().
The keyword form cannot express that, because it requires a closure by token, and the error message says so and names the component spelling.
A keyword resolves a name you did not write, so for needs For in scope and if needs Show.
use zgui::prelude::*; has both. If you import by name and forget one, the error tells you which.
For and Show props
Prop
Type
Prop
Type
while, loop and match are reserved: they may not name an element or a component, and writing
one is an error that points at for and if.
A view chosen at run time
Dynamic holds a closure producing a whole view, and swaps the subtree whenever the closure's
dependencies change.
use zgui::view::{AnyView, Dynamic};
view! {
box {
{Dynamic::new(move || match tab.get() {
Tab::Files => AnyView::new(view! { FileList() }),
Tab::Search => AnyView::new(view! { SearchPanel() }),
})}
}
}Dynamic::new takes an FnMut() -> AnyView. When the closure produces the same view type as last
time, the subtree is rebuilt in place; a different type replaces it.
Use it when the branches are not two but many, and their types differ. Prefer if when there are
two: if compares one boolean, while Dynamic re-runs whenever anything its closure read changed.
Rendering somewhere else
Portal renders its children on an overlay band instead of where it is written. A dialog, a menu,
a tooltip and a toast all have to escape whatever clipped, transformed or stacked ancestor they
were written inside.
use zgui::view::{OverlayLayer, Portal};
view! {
box(class = "row") {
control(on:click = move |_| open.set(true)) {"Open"}
if move || open.get() {
Portal(layer = OverlayLayer::Modal) {
box(class = "dialog") {"Really delete?"}
}
}
}
}Four bands exist, in paint order:
| Layer | For |
|---|---|
OverlayLayer::Content | portalled content that belongs with the page: a sticky region, an inline surface |
OverlayLayer::Popover | popovers, menus, tooltips, dropdowns — the default |
OverlayLayer::Modal | dialogs, sheets and drawers, which take the interaction over |
OverlayLayer::Toast | toasts and notices, which sit above everything including a dialog |
Naming the band, rather than relying on mount order, is what stops a toast raised before a dialog from painting beneath it.
The portal keeps a marker at the position it was written, so its siblings keep their order and the portal can come and go without disturbing them.
Overlays and portals covers positioning, dismissal and focus.
Nesting
Control flow is a node, so it nests wherever a node does — in an element, in a component, and in itself.
view! {
column {
for row in move || rows.get(), key = |r: &Row| r.id {
if move || row.visible.get() {
text {{row.label.clone()}}
}
}
}
}