zgui

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() { … } // correct

The 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 writeWhy
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:

  1. The row name. item is bound inside the block. Exactly one name — no tuple destructuring and no _. Destructure inside the body if you need to.
  2. The collection, after in, written as a closure. This is the list's only reactive dependency.
  3. 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| *n

Do 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:

KeywordComponent
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:

LayerFor
OverlayLayer::Contentportalled content that belongs with the page: a sticky region, an inline surface
OverlayLayer::Popoverpopovers, menus, tooltips, dropdowns — the default
OverlayLayer::Modaldialogs, sheets and drawers, which take the interaction over
OverlayLayer::Toasttoasts 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()}}
            }
        }
    }
}

Next

On this page