Skip to content

Design Keyed State

MeowLynxSea edited this page Jun 17, 2026 · 8 revisions

Keyed state

In modern declarative UI frameworks, persisting component state across re-renders is a core challenge. When the UI is reconstructed due to data changes, we want components to "remember" their state — text-input content, cursor position, checkbox selection. Yororen UI addresses this through the keyed state mechanism built on gpui's ElementId.

The core idea: associate each component's internal state with a stable ElementId. When the UI reconstructs, the previously saved state is recovered using the same identifier — state survives re-renders, focus changes, and re-parenting.

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

// Reconstructed on every render. The stable id keeps
// caret / selection / scroll / IME state intact across frames.
text_input("username")
    .placeholder("Enter your name")

ElementId: component identity

Yororen UI uses gpui's ElementId as the unique identifier for components. Every headless factory takes an impl Into<ElementId> as its first argument. Two forms:

1. Simple string ID

Suitable for components that are unique within a single page:

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")

2. Tuple ID (namespaced)

Suitable for collision avoidance — lists, complex components, multiple instances of the same id on one page:

text_input(("settings", "username"))
switch(("settings", "notifications"))
button(("modal", "close-button"), cx).caption("×")

Tuple IDs create a hierarchical structure: every ("settings", …) sits in the settings namespace, which makes debug logs and focus traversal much easier to follow.

id(...): the single identity API

Every headless factory exposes exactly one identity setter:

// In ButtonProps (representative — every headless factory has this shape)
pub fn id(mut self, id: impl Into<ElementId>) -> Self { ... }

Use id(...) for both general element identity and for list-row stable keys. In XML, <For key={...}> maps to the same underlying ElementId machinery — it gives each row a stable identity for recycling and state preservation.

Convention:

  • Use id(...) in general — "this element's unique identifier".
  • In lists / virtualized views, pick an id(...) value that is stable across reordering (e.g. the item's primary key, not its index).

Note: an older .key(...) alias existed in early versions but was removed to avoid confusion with <For key>; only .id(...) remains.

The 55 headless factories

All 55 built-in headless components accept an ElementId via .id(...). They are organized by category:

Category Factories
Buttons button, button_group, icon_button, toggle_button, split_button
Display label, text, heading, badge, tag, avatar, image, progress_bar, skeleton, divider, spacer, focus_ring, empty_state, icon, keybinding_display, shortcut_hint
Surfaces card, panel, tooltip
Inputs text_input, text_area, password_input, number_input, file_path_input, search_input, keybinding_input, select, combo_box
Controls switch, checkbox, radio, radio_group, slider
Overlays modal, popover, dropdown_menu, disclosure, overlay, menu
Notifications toast, notification
Lists list_item, tree, tree_item, table, form, form_field
Virtualization virtual_list, uniform_virtual_list

For the canonical list see crates/yororen-ui-core/src/renderer/markers.rs.

Default ids

Most factories take the id explicitly; nothing is implicit. A few that mint their own id when called without one (e.g. some composite factories) document this on their component page — read the component page before relying on a default.

Two sides of state preservation

There are exactly two mechanisms that work together to keep state alive across re-renders in a v0.3 app:

1. A stable id on the headless factory

For everything that lives on a *Props (text inputs, switches, a modal trigger, a custom list row), the ElementId you pass to the factory is the state key. gpui stores keyed state under that ElementId; if the same id appears in the next render, the prior state is recovered.

// 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

For app-level state that lives outside any single component (a Entity<MyApp> on cx.global::<MyApp>() or on cx.new(...)), the closure is what needs the entity clone:

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

let entity = cx.entity();        // Entity<MyApp> from Context<MyApp>

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

Why cx.entity() and not a borrowed handle:

  • It's the only way to get an Entity<MyApp> from inside a Context<MyApp> render closure.
  • Cloning is cheap (Entity is internally Arc-flavoured).
  • Each move closure that needs it must clone it once at construction time; you cannot borrow it across the move boundary.

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 (open edit modal, clear after submit), the component itself is the source of truth — derive the new value from your own state and the framework's render path will reconcile.

Some headless factories expose controlled-mode helpers (e.g. select::select and combo_box::combo_box accept value(...) for an externally-managed value). See the component page for specifics.

Virtualization and element namespace

In virtualized lists, the UI framework recycles row layouts that scroll out of view to render newly scrolling content. Without isolation, recycled rows would inherit each other's ids and state — a classic virtualization bug called state bleeding.

Row 1 renders:   input id = "field-name", state = "hello"
Row 1 scrolls out of view
Row 5 scrolls in and reuses Row 1's layout
Problem:        Row 5's input inherits Row 1's id and state

The headless virtual_list and uniform_virtual_list solve this two ways:

1. Stable per-row key from your data model

use yororen_ui::headless::virtual_list::{virtual_list, VirtualListController};

virtual_list("rows", &controller, cx)
    .row(move |ix, _w, cx| {
        // Always derive the row id from the data, never from the index.
        let item = &items[ix];
        list_item(("task-list", item.id), &item.title, cx)
            .render(cx)
            .into_any_element()
    })

2. Per-row element namespace

gpui::list calls the row closure inside a row-scoped with_element_namespace, so child ids collide freely across rows — two rows with an input called ("row", "field") will not share state. The same is true for uniform_virtual_list. See Design-Virtualization for the full pattern.

Best practices

  1. Derive ids from your data model. Use ids, UUIDs, or paths from your data, not render-time array indices.

    // Good
    list_item(("items", item.id), &item.name, cx)
    // Bad — index shifts after insert/delete
    list_item(("items", index), &item.name, cx)
  2. Explicit ids for stateful components. Even where defaults exist, explicit ids are more maintainable.

    // Good
    switch(("settings", "notifications"))
    // Avoid
    switch(("settings", "notifications")) // … actually this is fine — be explicit
  3. Tuple ids for logical grouping. Related components use a unified namespace prefix.

    // Good — creates a `settings` namespace
    text_input(("settings", "username"))
    switch(("settings", "notifications"))
    // Avoid — single string ids collide across the page
    text_input("username")
  4. One entity clone per closure. Every move closure that reaches back into your app state must let entity = entity.clone() at construction time. Don't borrow.

  5. Don't re-mint composite Entity<XxxState>. The Entity<XxxState> for a select, modal, popover etc. must be created once (XxxState::new(cx)) and reused across renders. Re-minting resets open/closed state every frame.

Related pages

Clone this wiki locally