Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

State Management

Blinc uses Stateful elements as the primary way to manage UI state. Stateful elements handle state transitions automatically without rebuilding the entire UI tree.

Stateful Elements

Stateful is a wrapper element that manages visual states (hover, press, focus, etc.) efficiently. When state changes, only the affected element updates - not the entire UI.

Basic Usage

#![allow(unused)]
fn main() {
use blinc_layout::prelude::*;

fn feature_card(label: &str, accent: Color) -> impl ElementBuilder {
    let label = label.to_string();

    stateful::<ButtonState>()
        .w_fit()
        .p(4.0)
        .rounded(14.0)
        .on_state(move |ctx| {
            let bg = match ctx.state() {
                ButtonState::Idle => accent,
                ButtonState::Hovered => Color::rgba(
                    (accent.r * 1.15).min(1.0),
                    (accent.g * 1.15).min(1.0),
                    (accent.b * 1.15).min(1.0),
                    accent.a,
                ),
                ButtonState::Pressed => Color::rgba(
                    accent.r * 0.85,
                    accent.g * 0.85,
                    accent.b * 0.85,
                    accent.a,
                ),
                ButtonState::Disabled => Color::GRAY,
            };

            div()
                .bg(bg)
                .on_click({
                    let label = label.clone();
                    move |_| println!("'{}' clicked!", label)
                })
                .child(text(&label).color(Color::WHITE))
        })
}
}

How It Works

  1. stateful::<S>() creates a StatefulBuilder for state type S
  2. .on_state(|ctx| ...) defines the callback that receives a StateContext
  3. Events (hover, click, etc.) trigger automatic state transitions
  4. ctx.state() returns the current state for pattern matching
  5. Return a Div from the callback - it’s merged onto the container

StateContext

The StateContext provides access to state and scoped utilities within your callback:

#![allow(unused)]
fn main() {
stateful::<ButtonState>()
    .on_state(|ctx| {
        // Get current state
        let state = ctx.state();

        // Create scoped signals (persist across rebuilds)
        let counter = ctx.use_signal("counter", || 0);

        // Create scoped animated values
        let opacity = ctx.use_animated_value("opacity", 1.0);

        // Access dependency values
        let value: i32 = ctx.dep(0).unwrap_or_default();

        // Dispatch events to trigger state transitions
        // ctx.dispatch(CUSTOM_EVENT);

        div().bg(color_for_state(state))
    })
}

StateContext Methods

MethodDescription
ctx.state()Get the current state value
ctx.event()Get the event that triggered this callback (if any)
ctx.use_signal(name, init)Create/retrieve a scoped signal (auto-subscribes)
ctx.subscribe(&signal)Subscribe this stateful to an externally-created signal’s changes
ctx.use_spring(name, target, config)Declarative spring animation (recommended)
ctx.spring(name, target)Declarative spring with default stiff config
ctx.use_animated_value(name, initial)Low-level animated value handle
ctx.use_timeline(name)Create/retrieve an animated timeline
ctx.dep::<T>(index)Get dependency value by index
ctx.dep_as_state::<T>(index)Get dependency as State handle
ctx.dispatch(event)Trigger a state transition

Event Access

Use ctx.event() to access the event that triggered the callback:

#![allow(unused)]
fn main() {
use blinc_core::events::event_types::*;

stateful::<ButtonState>()
    .on_state(|ctx| {
        // ctx.event() returns Some(EventContext) when triggered by user event
        // Returns None when triggered by dependency changes
        if let Some(event) = ctx.event() {
            match event.event_type {
                POINTER_UP => {
                    println!("Clicked at ({}, {})", event.local_x, event.local_y);
                }
                POINTER_ENTER => {
                    println!("Mouse entered!");
                }
                KEY_DOWN => {
                    if event.ctrl && event.key_code == 83 {  // Ctrl+S
                        println!("Save shortcut pressed!");
                    }
                }
                _ => {}
            }
        }

        let bg = match ctx.state() {
            ButtonState::Idle => Color::BLUE,
            ButtonState::Hovered => Color::CYAN,
            ButtonState::Pressed => Color::DARK_BLUE,
            _ => Color::GRAY,
        };

        div().bg(bg)
    })
}

EventContext Fields

FieldTypeDescription
event_typeu32Event type (POINTER_UP, POINTER_ENTER, etc.)
node_idLayoutNodeIdThe node that received the event
mouse_x, mouse_yf32Absolute mouse position
local_x, local_yf32Position relative to element bounds
bounds_x, bounds_yf32Element position (top-left corner)
bounds_width, bounds_heightf32Element dimensions
scroll_delta_x, scroll_delta_yf32Scroll delta (for SCROLL events)
drag_delta_x, drag_delta_yf32Drag offset (for DRAG events)
key_charOption<char>Character (for TEXT_INPUT events)
key_codeu32Key code (for KEY_DOWN/KEY_UP events)
shift, ctrl, alt, metaboolModifier key states

Setting Initial State

Use .initial() to set the initial state:

#![allow(unused)]
fn main() {
stateful::<ButtonState>()
    .initial(if disabled { ButtonState::Disabled } else { ButtonState::Idle })
    .on_state(|ctx| {
        // ...
        div()
    })
}

Signal Dependencies with .deps()

When a Stateful element needs to react to external signal changes (not just hover/press events), use .deps() to declare dependencies:

#![allow(unused)]
fn main() {
fn direction_toggle() -> impl ElementBuilder {
    // External state that affects the element's appearance
    let direction = use_state_keyed("direction", || Direction::Horizontal);

    stateful::<ButtonState>()
        .w(120.0)
        .h(40.0)
        .rounded(8.0)
        // Declare dependency - on_state re-runs when this signal changes
        .deps([direction.signal_id()])
        .on_state(move |ctx| {
            // Read the current direction value
            let dir = direction.get();
            let label = match dir {
                Direction::Horizontal => "Horizontal",
                Direction::Vertical => "Vertical",
            };

            let bg = match ctx.state() {
                ButtonState::Idle => Color::rgba(0.3, 0.5, 0.9, 1.0),
                ButtonState::Hovered => Color::rgba(0.4, 0.6, 1.0, 1.0),
                _ => Color::rgba(0.3, 0.5, 0.9, 1.0),
            };

            div()
                .bg(bg)
                .on_click(move |_| {
                    // Toggle direction
                    direction.update(|d| match d {
                        Direction::Horizontal => Direction::Vertical,
                        Direction::Vertical => Direction::Horizontal,
                    });
                })
                .child(text(label).color(Color::WHITE))
        })
}
}

Accessing Dependencies via StateContext

You can access dependency values directly from the context using ctx.dep():

#![allow(unused)]
fn main() {
let count_signal: State<i32> = use_state_keyed("count", || 0);
let name_signal: State<String> = use_state_keyed("name", || "".to_string());

stateful::<ButtonState>()
    .deps([count_signal.signal_id(), name_signal.signal_id()])
    .on_state(|ctx| {
        // Access by index (matches order in .deps())
        let count: i32 = ctx.dep(0).unwrap_or_default();
        let name: String = ctx.dep(1).unwrap_or_default();

        // Or get a full State<T> handle for reading and writing
        if let Some(count_state) = ctx.dep_as_state::<i32>(0) {
            let value = count_state.get();
            // count_state.set(value + 1);
        }

        div().child(text(&format!("{}: {}", name, count)))
    })
}

When to Use .deps()

Use .deps() when your on_state callback reads values from signals that can change independently of the element’s internal state transitions.

Without .deps(), the on_state callback only runs when:

  • The element’s state changes (Idle → Hovered, etc.)

With .deps(), it also runs when:

  • Any of the declared signal dependencies change

Scoped Signals

Use ctx.use_signal() for state that’s scoped to the stateful container:

#![allow(unused)]
fn main() {
stateful::<ButtonState>()
    .on_state(|ctx| {
        // This signal is keyed to this specific stateful container
        // Format: "{stateful_key}:signal:click_count"
        let click_count = ctx.use_signal("click_count", || 0);

        div()
            .child(text(&format!("Clicks: {}", click_count.get())))
            .on_click(move |_| {
                click_count.update(|n| n + 1);
            })
    })
}

Animated Values

Use ctx.use_spring() for declarative spring animations - specify the target and get the current animated value:

#![allow(unused)]
fn main() {
stateful::<ButtonState>()
    .on_state(|ctx| {
        // Declarative: specify target, get current value
        let target_scale = match ctx.state() {
            ButtonState::Hovered => 1.1,
            _ => 1.0,
        };
        let current_scale = ctx.use_spring("scale", target_scale, SpringConfig::wobbly());

        // For default stiff spring, use ctx.spring()
        let opacity = ctx.spring("opacity", if ctx.state() == ButtonState::Idle { 0.8 } else { 1.0 });

        div()
            .transform(Transform::scale(current_scale, current_scale))
            .opacity(opacity)
    })
}

Low-Level API

For more control, use ctx.use_animated_value() which returns a SharedAnimatedValue:

#![allow(unused)]
fn main() {
stateful::<ButtonState>()
    .on_state(|ctx| {
        // Get the animated value handle
        let scale = ctx.use_animated_value("scale", 1.0);

        // With custom spring config
        let opacity = ctx.use_animated_value_with_config(
            "opacity",
            1.0,
            SpringConfig::bouncy(),
        );

        // Manually set target and get value
        match ctx.state() {
            ButtonState::Hovered => {
                scale.lock().unwrap().set_target(1.1);
            }
            _ => {
                scale.lock().unwrap().set_target(1.0);
            }
        }

        let current_scale = scale.lock().unwrap().get();
        div().transform(Transform::scale(current_scale, current_scale))
    })
}

Animated Timelines

Use ctx.use_timeline() for complex multi-property animations with keyframes:

#![allow(unused)]
fn main() {
stateful::<ButtonState>()
    .on_state(|ctx| {
        // Persisted timeline scoped to this stateful
        let timeline = ctx.use_timeline("pulse");

        // Configure on first use, get existing entry IDs on subsequent calls
        let opacity_id = timeline.lock().unwrap().configure(|t| {
            let id = t.add(0, 1000, 0.5, 1.0);  // 0ms offset, 1000ms duration
            t.set_loop(-1);  // Loop forever
            t.start();
            id
        });

        let opacity = timeline.lock().unwrap().get(opacity_id);
        div().opacity(opacity)
    })
}

The configure() method is idempotent - it only runs the configuration closure on the first call and returns existing entry IDs on subsequent calls.


Built-in State Types

Blinc provides common state types with automatic transitions:

ButtonState

#![allow(unused)]
fn main() {
ButtonState::Idle      // Default state
ButtonState::Hovered   // Mouse over element
ButtonState::Pressed   // Mouse button down
ButtonState::Disabled  // Non-interactive
}

Transitions:

  • IdleHovered (on pointer enter)
  • HoveredIdle (on pointer leave)
  • HoveredPressed (on pointer down)
  • PressedHovered (on pointer up)

NoState

For containers that only need dependency tracking without state transitions:

#![allow(unused)]
fn main() {
stateful::<NoState>()
    .deps([some_signal.signal_id()])
    .on_state(|_ctx| {
        // Rebuilds when dependencies change
        div().child(text("Content"))
    })
}

Custom State Types

Define your own state enum for complex interactions. The StateTransitions trait has two transition methods:

  • on_event(&self, event: u32) -> Option<Self> — fires on a discrete user input (pointer, keyboard, custom events). Required.
  • on_tick(&self) -> Option<Self> — fires when a registered signal dependency changes, giving the state machine a chance to transition based on data without an event. Default returns None; override only when you need a data-guarded transition.

Event-driven transitions (on_event)

#![allow(unused)]
fn main() {
use blinc_layout::stateful::StateTransitions;
use blinc_core::events::event_types::*;

#[derive(Clone, Copy, PartialEq, Eq, Hash, Default, Debug)]
enum DragState {
    #[default]
    Idle,
    Hovering,
    Dragging,
}

impl StateTransitions for DragState {
    fn on_event(&self, event: u32) -> Option<Self> {
        match (self, event) {
            (DragState::Idle, POINTER_ENTER) => Some(DragState::Hovering),
            (DragState::Hovering, POINTER_LEAVE) => Some(DragState::Idle),
            (DragState::Hovering, POINTER_DOWN) => Some(DragState::Dragging),
            (DragState::Dragging, POINTER_UP) => Some(DragState::Idle),
            _ => None,
        }
    }
}

fn draggable_item() -> impl ElementBuilder {
    stateful::<DragState>()
        .w(100.0)
        .h(100.0)
        .rounded(8.0)
        .on_state(|ctx| {
            let bg = match ctx.state() {
                DragState::Idle => Color::BLUE,
                DragState::Hovering => Color::CYAN,
                DragState::Dragging => Color::GREEN,
            };
            div().bg(bg)
        })
}
}

Data-guarded transitions (on_tick)

on_tick is the Harel-statechart-style guard path: the state machine re-evaluates itself when a registered signal dependency changes, and may transition without an explicit event. The framework calls it during the deps-driven rebuild path; you read the relevant signals inside the impl and return Some(NextState) when a value condition is met.

Use it for state that should follow data automatically (loading completion, threshold crossings, timeouts driven by an external clock signal) rather than user input.

#![allow(unused)]
fn main() {
use blinc_layout::stateful::StateTransitions;
use blinc_core::use_state_keyed;

#[derive(Clone, Copy, PartialEq, Eq, Hash, Default, Debug)]
enum LoaderState {
    #[default]
    Loading,
    Done,
    Failed,
}

// A signal the state machine reads inside on_tick. Anything that
// can be read globally works — a keyed state, a singleton value,
// or a thread-local context.
use blinc_core::State;
fn progress_signal() -> State<f32> {
    use_state_keyed("loader_progress", || 0.0)
}

impl StateTransitions for LoaderState {
    // No user-input transitions for this loader.
    fn on_event(&self, _event: u32) -> Option<Self> { None }

    fn on_tick(&self) -> Option<Self> {
        let progress = progress_signal().get();
        match self {
            LoaderState::Loading if progress >= 1.0 => Some(LoaderState::Done),
            LoaderState::Loading if progress < 0.0  => Some(LoaderState::Failed),
            _ => None,
        }
    }
}

fn loader_view() -> impl ElementBuilder {
    let progress = progress_signal();

    stateful::<LoaderState>()
        .deps([progress.signal_id()])  // tick fires when this signal changes
        .on_state(|ctx| {
            match ctx.state() {
                LoaderState::Loading => div().child(text("Loading...")),
                LoaderState::Done    => div().child(text("Done!")),
                LoaderState::Failed  => div().child(text("Error")),
            }
        })
}
}

The state machine’s on_tick re-runs every time progress changes (because we registered it via .deps([...])), and the transition fires automatically when progress crosses one of the guard thresholds — no event handler needed.

on_event and on_tick compose: an FSM can use both. Events drive user-input transitions, on_tick drives data-conditioned transitions. The framework calls them on the appropriate paths.


Keyed State (Global Signals)

For state persisted across UI rebuilds with a string key:

#![allow(unused)]
fn main() {
let is_expanded = use_state_keyed("sidebar_expanded", || false);

// Read
let expanded = is_expanded.get();

// Update
is_expanded.set(true);
is_expanded.update(|v| !v);

// Get signal ID for use with .deps()
let signal_id = is_expanded.signal_id();
}

Prefer the bare auto-keyed form when each call site holds one slot — use_state (for State<T>) and use_fsm (for SharedState<S>) both derive their keys from the caller’s source location via #[track_caller], so you don’t have to invent + thread a string per slot. Keyed variants stay around for the cases auto-keying can’t cover (loops, reusable factories instantiated multiple times from the same line).


Reactive Property Bindings

Stateful elements aren’t the only way to make UI react to signals. Every Blinc element exposes a set of reactive property setters that accept either an eager value or a signal-bound reference at the same call site — when the signal changes, only that one property is updated. No rebuild, no on_state callback, no .deps([...]).

This is the channel .bg(&state) / .w(&computed) / .opacity(&signal) travel through.

Signal vs State — what to reach for

Two flavours of reactive value, both wired into the same property-binding registry. Pick by creation semantics, not capability — they support the same .get / .set / .update operations and both work in every reactive setter.

TypeCreated viaLifetimeWhen to reach for it
Signal<T>signal(initial), returned by use_signal_keyed(...)Slotmap-keyed in the process-global graphCopy — capture by value in closures without .clone(). Use anywhere; the primitive.
State<T>use_state(initial), use_state_keyed(k, init)Hook-keyed slot persisted across rebuildsUI-component-local state where call-site keying matters. Clone.

Both can be passed to .bg(...) etc. interchangeably:

#![allow(unused)]
fn main() {
let count: Signal<i32> = signal(0);          // bare primitive
let theme: State<Theme> = use_state(Theme::Dark);  // hook-keyed

div().bg(&theme_color_for(theme)).rounded(&radius_for(count));
}

Migration note: older code shows State<T> everywhere because Signal<T> only got its rich API in this release. They’re interoperable — mix freely.

Reactive<T> and IntoReactive<T>

Reactive setters take impl IntoReactive<T>. Four impls cover the common cases:

Pass inResolves toWhat happens
A value of TReactive::Const(T)Direct write at build time — no subscription
&Signal<T> or Signal<T>Reactive::Bound(state)Registers a subscription on the signal’s id; fires on every .set(...)
&State<T> or State<T>Reactive::Bound(state)Same as Signal<T> — same channel
&Computed<T> or Computed<T>Reactive::Computed(c)Registers a subscription on the derived id; fires when any tracked dependency of the computed changes

The call site doesn’t change — the type of the argument selects the behaviour:

#![allow(unused)]
fn main() {
use blinc_core::reactive::signal;            // bare reactive primitive
use blinc_core::context_state::use_state;    // hook-keyed
use blinc_layout::prelude::*;

let bg = signal(Color::from_hex(0x1a1a1a));  // Copy
let w  = use_state(120.0_f32);                // Clone

div()
    .bg(bg)         // Signal is Copy — pass by value
    .w(&w)          // State needs reference (Clone, not Copy)
    .rounded(8.0)   // eager — no subscription
}

There is no separate “bound” setter. The eager and bound forms share one method name, so you can swap a constant for a signal (or vice versa) by changing the argument alone.

Free functions: signal() / computed() / derived() / effect()

Four free functions provide the bare reactive-primitive surface, all operating against the process-global reactive graph:

#![allow(unused)]
fn main() {
use blinc_core::reactive::{signal, computed, derived, effect};

let count: Signal<i32> = signal(0);

// Computed (alias: `derived`). Auto-tracks every signal read inside
// the closure. Re-fires bindings when any tracked dep changes.
let doubled = computed(move |g| g.get(count).unwrap_or(0) * 2);

// Side effect — logging, IO, custom integrations.
let _e = effect(move |g| {
    println!("count = {}", g.get(count).unwrap_or(0));
});

// Drives both: bindings re-paint, effect re-prints.
count.set(5);
}

Signal<T> is Copy, so closures capture by value without .clone() ceremony:

#![allow(unused)]
fn main() {
let n = signal(0_i32);
let plus  = button("+").on_click(move |_| n.update(|v| v + 1));
let minus = button("-").on_click(move |_| n.update(|v| v - 1));
// Both closures captured `n` by copy — no boilerplate.
}

Reactive-aware Div setters

These all take impl IntoReactive<T> today:

SetterTChannel
.bg(value)ColorRenderProps (no relayout)
.opacity(value)f32RenderProps
.rounded(value)f32RenderProps
.border_color(value)ColorRenderProps
.shadow(value)ShadowRenderProps
.transform(value)TransformRenderProps
.scale(value)f32RenderProps (composes with existing transform)
.rotate(value) / .rotate_deg(value)f32RenderProps
.transform_width(value)f32 (0..=1)RenderProps — GPU scale-x, left-pivot. Use for cn::progress-style fill animations without relayout
`.bind_transform_from(source,vTransform::…)`
.w(value) / .h(value)f32taffy Style (triggers relayout)
.p(value)f32taffy Style (relayout)
.gap(value)f32taffy Style (relayout)

Visual-only updates skip compute_layout entirely — they just patch RenderProps and request a redraw. Layout-affecting updates patch the live taffy::Style and schedule one relayout next frame.

Computed (derived) values

use_computed(compute) returns a Computed<T> that lazily evaluates the closure and auto-tracks every signal it reads. Pass it to a reactive setter just like a State<T>:

#![allow(unused)]
fn main() {
use blinc_core::context_state::{use_state, use_computed};

let count = use_state(0_i32);
let label_color = {
    let count = count.clone();
    use_computed(move |_g| {
        if count.get() > 10 { Color::RED } else { Color::WHITE }
    })
};

div()
    .child(text("Count").color(&label_color))
    .on_click(move |_| count.update(|n| n + 1))
}

When count.set(...) fires, the registry walks every derived that depends on it (here: label_color), marks it dirty, and re-fires every property binding subscribed to that derived. Only the text’s colour is patched — no rebuild.

Computed<T> exposes .get() for ad-hoc reads, but the common case is to hand it straight to a setter and let the registry drive it.

Reactive bindings vs .deps() + on_state

Both routes “make UI react to a signal”. They aren’t equivalent — pick by what you’re updating:

Use…When
Reactive setter (.bg(&state), .w(&state), …)Patching a single property on a known element. Cheapest path — no callback, no rebuild
.deps([…]) + on_stateThe signal change needs to restructure the subtree (different children, different conditional branches) or read multiple signals to produce a Div

A 1-to-1 mapping (signal → one property) belongs in a reactive setter. A 1-to-many or “rebuild this whole region” relationship belongs in on_state.

Lifecycle

Reactive bindings register against the LayoutNodeId that owns them. When remove_subtree_nodes drops the node — structural rebuild, unmount, conditional removal — PropertyBindingRegistry::unregister_node evicts every binding for that node so stale subscribers can’t fire. Cleanup is automatic; you never call .unsubscribe().


Persistent Stateful Handles (SharedState<S>)

Blinc has two distinct persistent-state abstractions and the names get confusing without context — picking the right one comes down to what you’re storing:

AbstractionReturnsUse forConstructors
State<T>A signal-backed slot with .get() / .set() / .update() and a signal_id() for .deps([…])Fine-grained reactive values — counters, flags, form fields, anything one place writes and another reads via signalsuse_state(initial) (bare, #[track_caller]), use_state_keyed(key, init)
SharedState<S>An Arc<Mutex<StatefulInner<S>>> — the handle a Stateful<S> widget hangs its FSM off ofStateful UI elements with discrete states (hover / press / drag / custom state machines), shared across call sites or driven from external eventsuse_fsm(initial) (bare, #[track_caller]), use_fsm_keyed(key, initial)

TL;DR: use_state returns State<T> (a signal). use_fsm returns SharedState<S> (an FSM handle). They are not interchangeable. If you reach for one and the type-checker rejects it, you almost certainly want the other.

When you build a Stateful<S> widget outside an on_state closure — typically because you want to share its FSM with multiple call sites or drive it from external events — you need a SharedState<S> handle that survives UI rebuilds. Two factory functions cover this:

#![allow(unused)]
fn main() {
use blinc_layout::prelude::*;
use blinc_layout::stateful::{ButtonState, use_fsm, use_fsm_keyed};

// Bare — keyed by the source location of THIS call via `#[track_caller]`.
// One slot per source line. Don't use inside a loop.
let modal_btn   = use_fsm(ButtonState::Idle);
let toast_btn   = use_fsm(ButtonState::Idle);
let dialog_btn  = use_fsm(ButtonState::Idle);

// Explicit — keyed by anything `Hash` (string, integer, tuple,
// InstanceKey, ...). Use this for loops, list items, or reusable
// component factories called multiple times from the same line.
for entry in items.iter() {
    let entry_btn = use_fsm_keyed(entry.id, ButtonState::Idle);
    // …
}
}

The same Hash key plus the same S always returns the same Arc<Mutex<StatefulInner<S>>>, so state survives subtree rebuilds — the slot lives in the process-wide BlincContextState reactive graph, not in the layout tree.

#[track_caller] and widget wrappers

#[track_caller] is forwarding, not generating. If a widget factory is tagged #[track_caller] and calls use_fsm internally, Location::caller() returns the user’s call site, not the factory’s body. Two distinct call sites give two distinct slots — which is what you want for the common case:

#![allow(unused)]
fn main() {
#[track_caller]
fn my_button(label: &str) -> impl ElementBuilder {
    // Forwarded — `use_fsm` sees the caller's source line.
    let handle = use_fsm(ButtonState::Idle);
    stateful_from_handle(handle).on_state(/* … */)
}

fn settings_page() -> impl ElementBuilder {
    div()
        .child(my_button("Save"))    // line 42 → unique slot
        .child(my_button("Cancel"))  // line 43 → unique slot
}
}

The loop case is the trap. #[track_caller] still forwards the caller’s location, but every iteration of a loop calls from the same line, so every iteration collides on the same slot:

#![allow(unused)]
fn main() {
// 🚫 BUG — all 5 buttons share one ButtonState handle:
for i in 0..5 {
    col = col.child(my_button("Item"));  // line 47, every iteration
}

// ✅ Pass an explicit per-instance key:
for i in 0..5 {
    col = col.child(my_button_keyed(i, "Item"));
}

#[track_caller]
fn my_button_keyed(id: u32, label: &str) -> impl ElementBuilder {
    let handle = use_fsm_keyed(id, ButtonState::Idle);
    stateful_from_handle(handle).on_state(/* … */)
}
}

Mental model

ScenarioAPIKey
One widget per source lineuse_fsm(initial)(file, line, column) via #[track_caller]
Loop body / .map() / repeated factory calluse_fsm_keyed(k, initial)Per-iteration data: index, id, tuple, InstanceKey
Different widget types from the same lineuse_fsm(initial) worksKey is also typed on SharedState<S>, so two calls with different S from one line still get distinct slots

The same split exists for plain reactive cells (State<T>):

State typeBare auto-keyedExplicit key
State<T> (basic reactive value)use_state(initial)use_state_keyed(key, init)
SharedState<S> (FSM handle)use_fsm(initial)use_fsm_keyed(key, initial)

Why this works across rebuilds

A subtree rebuild replaces layout nodes, but LayoutNodeIds aren’t the identity stateful state hangs off of — that lives in the process-wide hooks store, keyed by (call_site, S) (or (explicit_key, S)). Rebuilds tear down and re-mint layout nodes but the source location of use_fsm doesn’t change, so the same slot is found on the next call. Combined with StableNodeIds (which make event routing survive rebuilds), Stateful widgets keep their internal FSM state, scoped signals, and registered springs / keyframes across every rebuild.


Best Practices

  1. Use stateful::<S>() builder - This is the primary pattern for stateful UI elements.

  2. Return Div from callbacks - The new API expects you to return a Div, not mutate a container.

  3. Use .initial() for non-default states - Set initial state explicitly when needed.

  4. Use ctx.use_signal() for local state - Scoped signals are automatically keyed.

  5. Use ctx.dep() for dependency access - Cleaner than capturing signals in closures.

  6. Prefer built-in state types - They have correct transitions already defined.

  7. Custom states for complex flows - Define your own when built-in types don’t fit.

  8. Use .deps() for external dependencies - When on_state needs to react to signal changes.

  9. Prefer the bare auto-keyed variantuse_state(initial) for State<T>, use_fsm(initial) for SharedState<S>. Reach for the _keyed variants only when one source line produces multiple instances (loops, reusable factories).