Skip to content

Design Keyed State

MeowLynxSea edited this page Jun 18, 2026 · 8 revisions

Keyed state

State survives re-renders because gpui keys it on a stable ElementId. Every headless factory takes an impl Into<ElementId> as its first argument.

use yororen_ui::headless::button::button;
use yororen_ui::headless::text_input::text_input;

button("submit-btn", cx).caption("Save")
text_input("username").placeholder("Name")

Id forms

Simple string — unique on the page:

button("submit-btn", cx)

Tuple — namespaced:

text_input(("settings", "username"))
switch(("settings", "notifications"))

Tuple ids create a hierarchical structure ("settings" namespace) that makes debug logs and focus traversal easier to follow.

Two mechanisms, working together

  1. A stable id on the headless factory keeps the component's internal state alive (caret, selection, scroll, IME for text inputs).

    // frame N
    text_input("email").placeholder("you@example.com")
    // frame N+1 — same id, caret / selection / value restored
    text_input("email").placeholder("you@example.com")
  2. cx.entity().clone() for closures keeps your app state reachable from callbacks:

    let entity = cx.entity();
    
    text_input("email")
        .on_change({
            let entity = entity.clone();
            move |new: &str, _w, cx| {
                entity.update(cx, |s, _| s.email = new.to_string());
            }
        })
        .render(cx, window)

The id keeps the component's internal state alive; the entity clone keeps your app's state reachable. Both are required.

Controlled vs uncontrolled

Yororen UI inputs are uncontrolled by default. The component owns caret / selection / IME state internally; on_change is the bridge from the component to your app state. If you need to reset the field, bump the id so a fresh state is minted.

Virtualisation

In a virtualised list, the row closure runs inside a row-scoped element namespace — child ids collide freely across rows. Without this isolation, recycled rows would inherit each other's state ("state bleeding"). Two consequences:

  • Derive ids from your data model (a UUID, a primary key, a path), not the row index.
  • You don't need to manually namespace child ids inside a row closure — the renderer does it for you.
virtual_list("rows", &controller, cx)
    .row(move |ix, _w, cx| {
        let item = &items[ix];
        list_item(("task-list", item.id), &item.title, cx).render(cx).into_any_element()
    })

Best practices

  1. Derive ids from your data model. Index-based ids shift after insert/delete.
  2. Tuple ids for logical grouping. Settings row ids under ("settings", …); sidebar under ("sidebar", …).
  3. One entity clone per closure. Each move closure that reaches back into your app state must let entity = entity.clone() at construction.
  4. Don't re-mint composite Entity<XxxState>. Create once (XxxState::new(&mut *cx)) and reuse across renders; re-minting resets open/closed state every frame.

See also

Clone this wiki locally