Keyed State

Order-independent state with the state! macro.

// Safe inside conditionals
if show_counter {
    let count = state!(cx, || 0);  // unique key per call site
    count.update(|n| *n += 1);
}

Run with: cargo run -p telex-tui --example 27_keyed_state

The Hook Order Problem

React developers know this rule: hooks must be called in the same order every render. Index-based state has this restriction — if you call state hooks conditionally, indices shift between renders, causing type mismatches and panics.

Telex solves this with the state! macro, which uses key-based storage instead of index-based. Each state! call gets a unique compile-time key based on its source location, so order never matters.

The Solution: state!

The state! macro creates keyed state - each invocation gets a unique compile-time key based on its code location:

// ✅ CORRECT - This works!
fn render(&self, cx: Scope) -> View {
    if show_counter {
        let count = state!(cx, || 0);  // Key: unique to this line
    }
    let name = state!(cx, String::new);  // Key: unique to this line
}

Each state! call generates a unique zero-sized type as its key, so order doesn't matter.

How It Works

Under the hood, state! expands to use_state_keyed with an automatically generated key type:

// What you write:
let count = state!(cx, || 0);

// What the macro generates (simplified):
struct __Key_file_line_27_col_17;
let count = cx.use_state_keyed::<__Key_file_line_27_col_17, _>(|| 0);

The key type is unique to that exact location in your source code, so each state! call gets its own storage slot.

Basic Usage

let enabled = state!(cx, || true);

enabled.get()              // read
enabled.set(false)         // write
enabled.update(|b| *b = !*b)  // toggle

The API is identical to use_state_keyed - only the creation differs (automatic key vs explicit key).

Conditional State

The killer feature: state that exists only when needed:

let show_advanced = state!(cx, || false);

if show_advanced.get() {
    let detail_level = state!(cx, || 5);
    let custom_option = state!(cx, String::new);

    // Build advanced UI using these states
}

When show_advanced is false, the detail_level and custom_option states aren't created. When it becomes true, they're initialized. When it becomes false again, the values are preserved - turning it back on shows the same values.

Real-World Example

A settings panel with collapsible sections:

let show_notifications = state!(cx, || false);
let show_appearance = state!(cx, || false);
let show_advanced = state!(cx, || false);

View::vstack()
    .child(section_header("Notifications", show_notifications.clone()))
    .child(if show_notifications.get() {
        let email = state!(cx, || true);
        let push = state!(cx, || true);
        let sms = state!(cx, || false);

        View::vstack()
            .child(checkbox("Email notifications", email))
            .child(checkbox("Push notifications", push))
            .child(checkbox("SMS notifications", sms))
            .build()
    } else {
        View::empty()
    })
    .child(section_header("Appearance", show_appearance.clone()))
    .child(if show_appearance.get() {
        let theme = state!(cx, || "dark".to_string());
        let font_size = state!(cx, || 14);

        View::vstack()
            .child(theme_picker(theme))
            .child(font_size_slider(font_size))
            .build()
    } else {
        View::empty()
    })
    .build()

Each section's state only exists when expanded, but values persist across collapse/expand.

Dynamic Lists

Create state for dynamic collections:

let items = state!(cx, || vec![
    "Item 1".to_string(),
    "Item 2".to_string(),
]);

// Each item gets conditional state
for (i, item) in items.get().iter().enumerate() {
    if i == selected.get() {
        // State that only exists for the selected item
        let editing = state!(cx, || false);
        let draft = state!(cx, || item.clone());

        // Edit UI
    }
}

Important: This works because each state! call is at a unique source location. The loop body is the same location, so all iterations share the same key. For per-item state, see Shared State.

state! vs use_state_keyed

Use state! for almost everything — it handles ordering automatically and is safe in conditionals.

Use cx.use_state_keyed::<Key, _>(|| init) when:

  • You need shared state across multiple call sites (same key = same state)
  • You need explicit control over which state slot is used
// Shared state — same key type = same state everywhere
struct ThemeKey;
let theme = cx.use_state_keyed::<ThemeKey, _>(|| "dark".to_string());

Multiple Conditionals

You can nest conditionals freely:

if mode.get() == "advanced" {
    let option_a = state!(cx, || false);

    if option_a.get() {
        let sub_option = state!(cx, || "default");
        // Use sub_option
    }
}

Each state! has its own unique key, so they never interfere.

State Lifecycle

Creation: State is created the first time its state! call is executed.

Persistence: Once created, state persists even if the conditional becomes false. It's stored by key, not by execution path.

Cleanup: State is only cleaned up when the component unmounts.

This means:

if show_counter {
    let count = state!(cx, || 0);
    count.update(|n| *n += 1);
}

// Later, show_counter becomes false, then true again
// The count value is preserved - it didn't reset to 0

Common Patterns

Toggle with state:

let expanded = state!(cx, || false);
let toggle = with!(expanded => move || expanded.update(|b| *b = !*b));

View::button()
    .label(if expanded.get() { "Collapse" } else { "Expand" })
    .on_press(toggle)
    .build()

Conditional form:

if show_form.get() {
    let name = state!(cx, String::new);
    let email = state!(cx, String::new);

    View::vstack()
        .child(input_field("Name", name))
        .child(input_field("Email", email))
        .child(submit_button(name, email))
        .build()
}

Mode-based state:

match mode.get() {
    Mode::Edit => {
        let draft = state!(cx, || original.clone());
        edit_view(draft)
    }
    Mode::View => {
        view_display(original)
    }
    Mode::Preview => {
        let zoom = state!(cx, || 100);
        preview_view(original, zoom)
    }
}

Each mode gets its own isolated state.

Debugging

If you see "State type mismatch" with state!, you might have:

// Same location, different types on different renders
if condition_a {
    let x = state!(cx, || 0i32);  // Key_123 -> i32
}
if condition_b {
    let x = state!(cx, || "hello");  // Key_123 -> String (CONFLICT!)
}

Solution: Use different variable names or move them to different locations:

if condition_a {
    let num = state!(cx, || 0i32);
}
if condition_b {
    let text = state!(cx, || "hello");
}

Performance

state! uses O(1) hash map access for key lookup — completely negligible in UI code.

Limitations

state! keys are based on source location, not execution context:

// This doesn't work as you might expect
for i in 0..5 {
    let count = state!(cx, || 0);  // All iterations share the SAME state!
}

Each loop iteration is the same source location, so they get the same key. For per-item state, use explicit keys with cx.use_state_keyed (see Shared State).

Tips

Default to state! - Unless you need shared state across call sites, state! is the way to go.

Location matters - Moving state! to a different line creates a new state. Don't refactor carelessly.

Works with all types - Any Clone + 'static type works with state!.

Clone is cheap - State<T> is Rc<RefCell<T>> under the hood, so cloning the handle is cheap.

Next: Shared State