-
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—such as text input content, cursor position, or checkbox selection. Yororen-UI addresses this through the Keyed State mechanism.
The core idea of Keyed State is to associate a component's internal state with a stable identifier (ElementId). When the UI reconstructs, the previously saved state is recovered using the same identifier, enabling state persistence across render cycles.
// This input is reconstructed on every render
// But because it uses a stable ID, internal state (cursor, selection) is preserved
text_input()
.id("username") // Stable identifier
.placeholder("Enter your name");Yororen-UI uses gpui's ElementId type as the unique identifier for components. It supports two forms:
Suitable for components that are unique within a single page:
text_input().id("username")
button().id("submit-btn")Suitable for scenarios requiring collision avoidance, especially lists and complex components:
// ("namespace", "specific-identifier")
text_input().id(("settings", "username"))
switch().id(("settings", "notifications"))
button().id(("modal", "close-button"))The advantage of tuple IDs is that they automatically create a hierarchical structure. For example, in a settings form, all settings-related components use ("settings", ...) as a prefix, making code more readable and easier to locate elements during debugging.
All 56 Yororen-UI components implement the id() method:
// id() implementation in Button
pub fn id(mut self, id: impl Into<ElementId>) -> Self {
self.element_id = id.into();
self
}
// key() is an alias for id(), semantically emphasizing "state identity"
pub fn key(self, key: impl Into<ElementId>) -> Self {
self.id(key)
}Usage guidelines:
- Use
id(): General scenarios, emphasizing "this is the element's unique identifier" - Use
key(): In lists, virtualization, etc., emphasizing "this is the list item's stable key"
All components implementing #[derive(IntoElement)] provide the id() method. Here's the complete list organized by category:
| Category | Components |
|---|---|
| Form Inputs | TextInput, TextArea, PasswordInput, NumberInput, FilePathInput, SearchInput, Checkbox, Radio, Switch, Slider, ComboBox, Select, KeybindingInput |
| Buttons | Button, IconButton, SplitButton, ToggleButton |
| Layout | Card, Divider, Spacer, EmptyState |
| Data Display | Label, Text, Heading, Badge, Tag, Avatar, Image, Progress, Skeleton |
| Navigation | ListItem, Tree, TreeItem, TreeNode |
| Overlays | Popover, DropdownMenu, Modal, Tooltip, Toast |
| Feedback | Disclosure, DragHandle, FocusRing, ClickableSurface |
| Groups | ButtonGroup, RadioGroup |
| Form | Form, FormRow, ValidationStateIcon, HelpText, InlineError |
| Utilities | VirtualRow, ContextMenuTrigger |
Some components provide default IDs in their constructor:
| Component | Default ID |
|---|---|
| Button | "ui:button" |
| TextInput | "ui:text-input" |
| Select | "ui:select" |
| Checkbox | "ui:checkbox" |
| Tree | "ui:tree" |
| Popover | "ui:popover" |
| DropdownMenu | "ui:dropdown-menu" |
| Modal | "modal" |
Best practice: Always explicitly provide stable IDs for stateful components rather than relying on defaults. Explicit IDs are more maintainable and easier to debug.
Complex compound components often contain multiple child elements (e.g., Popover contains a trigger and content area). Yororen-UI provides the child_id() method for such components, generating child element IDs by combining the parent component's ID with a suffix:
// Implementation in progress.rs
pub fn child_id(&self, suffix: &str) -> ElementId {
(self.element_id.clone(), suffix.to_string()).into()
}This design avoids child element ID collisions while maintaining code simplicity.
The following 13 components provide child_id() for child element ID generation:
| Component | Generated Child IDs |
|---|---|
| Popover |
"popover-trigger", "popover-content"
|
| DropdownMenu |
"dropdown-trigger", "dropdown-menu"
|
| SplitButton |
"split-button-trigger", "split-button-menu"
|
| RadioGroup |
"radio-group-label", "radio-group-0", ... |
| TreeItem |
"tree-item-toggle", "tree-item-checkbox"
|
| Progress |
"progress-indicator", "progress-track"
|
| EmptyState |
"empty-state-icon", "empty-state-title"
|
| SearchInput |
"search-input-field", "search-input-clear"
|
| KeybindingInput |
"keybinding-input-field", "keybinding-input-clear"
|
| FilePathInput |
"file-path-input-field", "file-path-input-button"
|
| Modal |
"modal-close-button", "modal-title"
|
use yororen_ui::component::{popover, button, label};
// Create a Popover with an explicit ID
let popover = popover("user-menu")
.trigger(button("Open").child("Click me"))
.content(label("Popover content").into_any_element());
// Programmatically generate child element IDs
let trigger_id = popover.child_id("trigger"); // "user-menu-trigger"
let content_id = popover.child_id("content"); // "user-menu-content"Child element IDs are primarily used for:
- Automated testing: Selecting elements by stable IDs
- External interaction: Triggering specific child element behaviors from outside the component
- Accessibility support: Associating labels with input elements
gpui provides the Window::use_keyed_state() method for storing and retrieving state in the window context. Yororen-UI components extensively use this mechanism for internal state management:
// Usage in text_input.rs
let state = window.use_keyed_state(id.clone(), cx, |_, cx| TextInputState::new(cx));When the same ElementId appears again, gpui returns the previously stored state instead of creating new state. This is why components can "remember" their state after UI reconstruction.
For complex components, a single key may not be sufficient. Yororen-UI uses tuple IDs to create namespace isolation:
// keybinding_input.rs - managing multiple state items for one component
window.use_keyed_state((id.clone(), "ui:keybinding:value"), cx, |_, _| { ... });
window.use_keyed_state((id.clone(), "ui:keybinding:active"), cx, |_, _| false);
window.use_keyed_state((id.clone(), "ui:keybinding:modifiers"), cx, |_, _| { ... });helpers.rs provides a set of helper functions to simplify keyed state usage:
// Create internal state
pub fn create_internal_state<T: Clone + Default + 'static>(
window: &mut Window,
cx: &mut App,
id: &ElementId,
key: String,
default_value: T,
should_use: bool,
) -> Option<Entity<T>> {
if should_use {
Some(window.use_keyed_state((id.clone(), key), cx, |_, _| default_value))
} else {
None
}
}Yororen-UI components support both controlled and uncontrolled modes:
-
Controlled mode: The component's value is managed externally via the
valueparameter, and changes are communicated viaon_changecallbacks -
Uncontrolled mode: The component fully manages its own internal state, persisted using
use_keyed_state
// State resolution in helpers.rs
pub fn resolve_controlled_state<T: Clone + Default + 'static>(
external: Option<&T>, // Controlled: externally provided value
internal: Option<&Entity<T>>, // Uncontrolled: internal state
cx: &App,
default_value: T,
) -> T {
if let Some(value) = external {
return value.clone(); // Controlled mode takes precedence
}
if let Some(internal) = internal {
return internal.read(cx).clone();
}
default_value
}In virtualized lists, the UI framework recycles row layouts that scroll out of view to render newly scrolling content. This presents a state management challenge:
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!
This is known as state bleeding, a classic problem in virtualized list development.
The VirtualRow component addresses this through two mechanisms:
// virtual_row.rs
pub fn key(self, key: impl Into<ElementId>) -> Self {
self.id(key)
}
// Set as tuple ID during rendering
div().id((key.clone(), "virtual-row"))Each list item must provide a stable key (typically from the data model's ID):
virtual_row(("items", item_id)) // item_id comes from your data model
.child(list_item().content(item.name))VirtualRow uses window.with_element_namespace() during rendering to create isolated namespaces:
// virtual_row.rs
window.with_element_namespace((key.clone(), "virtual-row-ns"), |_window| {
div()
.id((key.clone(), "virtual-row"))
.flex()
.flex_col()
// ... render content
})This means:
- Row 1's internal elements are in namespace
("item-1", "virtual-row-ns") - Row 5's internal elements are in namespace
("item-5", "virtual-row-ns") - Even if both rows have an input with ID
"field-name", they won't collide
use yororen_ui::component::{list_item, virtual_row};
use yororen_ui::widget::{virtual_list, virtual_list_state};
// Virtual lists require VirtualRow wrapper
let list = virtual_list(state, move |ix, _window, _cx| -> AnyElement {
// ⚠️ Critical: Use stable IDs from your data model, not array indices
let item_id = items[ix].id;
virtual_row(("items", item_id))
.gap_below(px(6.))
.child(list_item().content(items[ix].name.clone()))
.into_any_element()
});use yororen_ui::component::text_input;
// Simplest usage
let input = text_input()
.id("username") // Stable ID
.placeholder("Enter username");use yororen_ui::component::{text_input, switch, button};
// Use tuple IDs for logical grouping
let form = div()
.child(text_input()
.id(("login", "username"))
.placeholder("Username"))
.child(text_input()
.id(("login", "password"))
.placeholder("Password"))
.child(switch()
.id(("login", "remember-me"))
.label("Remember me"))
.child(button(("login", "submit")).label("Sign In"));use gpui::ListAlignment;
use yororen_ui::component::{list_item, virtual_row};
use yororen_ui::widget::{virtual_list, virtual_list_state};
// Assuming items is your data list
let state = virtual_list_state(items.len(), ListAlignment::Top, px(48.));
let list = virtual_list(state, move |ix, _window, _cx| {
// Must use stable data IDs
let item = &items[ix];
let item_key = ("task-list", item.id); // Stable key
virtual_row(item_key)
.child(list_item()
.content(item.title.clone())
.checkbox(item.completed))
.into_any_element()
});You can also use keyed state externally to persist arbitrary data:
use gpui::SharedString;
// Create keyed state at window initialization
let username = window.use_keyed_state("settings:username", cx, |_, _| {
SharedString::new_static("")
});
let notifications = window.use_keyed_state("settings:notifications", cx, |_, _| true);
// Use in UI
let view = div()
.child(text_input()
.key(("settings", "username"))
.placeholder("Username"))
.child(switch()
.key(("settings", "notifications"))
.label("Enable notifications"));use yororen_ui::component::{popover, button, label, icon, IconName};
let popover = popover("user-menu")
.trigger(icon_button("menu-trigger")
.icon(icon(IconName::Menu)))
.content(div()
.child(label("User Settings"))
.child(label("Log Out")));
// Get child IDs for testing or interaction
let trigger_id = popover.child_id("trigger");
let content_id = popover.child_id("content");| Scenario | Description |
|---|---|
| Repeated UI in lists/grids | Each list item needs a unique identifier to maintain its expansion state, selection state, etc. |
| Virtualized rows | VirtualRow's recycling mechanism requires stable keys to prevent state confusion |
| Reorderable UI | When dragging to reorder, IDs must come from data, not position |
| Form fields | Each input needs a stable ID for validation state, error messages, etc. |
| Components with internal state | Any component managing toggle, selection, expansion, etc. |
-
Derive IDs from your data model: Use IDs, UUIDs, or paths from your data, not render-time array indices
// ✅ Recommended: Use data ID virtual_row(("items", item.id)) // ❌ Avoid: Using indices (will shift after insert/delete) virtual_row(("items", index))
-
Explicitly provide IDs for stateful components: Even though some components have defaults, explicit IDs are more maintainable
// ✅ Recommended checkbox().id("accept-terms") // ❌ Not recommended (relies on default) checkbox()
-
Use tuple IDs for logical grouping: Related components use a unified namespace prefix
// ✅ Recommended: Creates "settings" namespace text_input().id(("settings", "username")) switch().id(("settings", "notifications")) // ❌ Avoid: Globally unique simple strings may collide text_input().id("username") // What if there are multiple "username"s on the page?
-
Always use VirtualRow in virtualized lists: Don't render components directly in virtual lists
// ✅ Recommended virtual_row(("items", id)).child(...) // ❌ Avoid div().id(("items", id)).child(...) // Missing namespace isolation
| Misconception | Correct Approach |
|---|---|
| "Using indices as keys in lists is fine" | Indices change on insert/delete, causing state misalignment |
| "All components need IDs" | Only stateful components need explicit IDs; purely presentational components can omit them |
| "Shorter IDs are better" | Meaningful IDs are easier to debug; "form-username" is better than "a"
|
| "Virtualized lists don't need special handling" | Must use VirtualRow + stable key + element namespace |
Yororen-UI's Key/ID system provides:
-
Complete coverage: All 56 IntoElement components provide the
id()method -
Child ID generation: 13 components provide
child_id()for child element identification - Virtualization safety: VirtualRow prevents state bleeding through element namespace isolation
- Flexible naming: Simple string IDs + tuple IDs support arbitrary hierarchical structures
- State isolation: Each component instance maintains its own state via keyed state mechanism
- Dual mode support: Supports both controlled (externally managed) and uncontrolled (internally managed) modes
Understanding and correctly using this system is key to building stable, efficient Yororen-UI applications.
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.