-
Notifications
You must be signed in to change notification settings - Fork 9
Design 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")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:
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")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.
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.
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.
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.
There are exactly two mechanisms that work together to keep state alive across re-renders in a v0.3 app:
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")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 aContext<MyApp>render closure. - Cloning is cheap (
Entityis internallyArc-flavoured). - Each
moveclosure 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.
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.
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:
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()
})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.
-
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)
-
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
-
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")
-
One entity clone per closure. Every
moveclosure that reaches back into your app state mustlet entity = entity.clone()at construction time. Don't borrow. -
Don't re-mint composite
Entity<XxxState>. TheEntity<XxxState>for aselect,modal,popoveretc. must be created once (XxxState::new(cx)) and reused across renders. Re-minting resets open/closed state every frame.
- Design-Three-Layer-Architecture — id is part of the headless contract.
- Design-Virtualization — namespace isolation per row.
- Design-Accessibility — focus + keyboard nav build on the same id system.
-
Composing UI — the
cx.entity().clone()pattern in practice.
Yororen UI v0.3.0 · repository · Apache-2.0 · This wiki documents Yororen UI v0.3.0.
This wiki documents Yororen UI v0.3.0 — the headless-core, swappable-renderer build.