Skip to content

Design Keyed State

MeowLynxSea edited this page Feb 19, 2026 · 8 revisions

Yororen-UI Keyed State Design Guide

Overview

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

Fundamental Concepts

ElementId: Component Identity

Yororen-UI uses gpui's ElementId type as the unique identifier for components. It supports two forms:

1. Simple String ID

Suitable for components that are unique within a single page:

text_input().id("username")
button().id("submit-btn")

2. Tuple ID (Namespaced)

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.

id() vs key(): Two Syntaxes

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"

ID System Architecture

Complete Method Coverage

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

Default IDs

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.


Child Element ID Generation

The child_id() Method

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.

Components Supporting child_id()

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"

Usage Example

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

Keyed State Internal Mechanism

Window::use_keyed_state: The Core of State Storage

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.

Namespaced Keyed State

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, |_, _| { ... });

Helper Functions

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

Controlled vs Uncontrolled Mode

Yororen-UI components support both controlled and uncontrolled modes:

  • Controlled mode: The component's value is managed externally via the value parameter, and changes are communicated via on_change callbacks
  • 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
}

Virtualization and Element Namespace

Why Virtualization Requires Special Handling

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.

VirtualRow's Solution

The VirtualRow component addresses this through two mechanisms:

1. Stable Per-Row Key

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

2. Row-Local Element Namespace

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

Correct Usage

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()
});

Practical Examples

Example 1: Basic Input Component

use yororen_ui::component::text_input;

// Simplest usage
let input = text_input()
    .id("username")  // Stable ID
    .placeholder("Enter username");

Example 2: Forms with Namespaces

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

Example 3: Virtualized List

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()
});

Example 4: Custom Keyed State

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

Example 5: Compound Components with Child IDs

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

Usage Scenarios and Best Practices

Scenarios Requiring Stable IDs

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.

Best Practices

  1. 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))
  2. 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()
  3. 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?
  4. 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

Common Misconceptions

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

Technical Summary

Yororen-UI's Key/ID system provides:

  1. Complete coverage: All 56 IntoElement components provide the id() method
  2. Child ID generation: 13 components provide child_id() for child element identification
  3. Virtualization safety: VirtualRow prevents state bleeding through element namespace isolation
  4. Flexible naming: Simple string IDs + tuple IDs support arbitrary hierarchical structures
  5. State isolation: Each component instance maintains its own state via keyed state mechanism
  6. 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.

Clone this wiki locally