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