Reactive State System
Blinc implements a push-pull hybrid reactive system for fine-grained state management without virtual DOM overhead. This is inspired by modern reactive frameworks like Leptos and SolidJS.
Core Concepts
State<T>
A State<T> is a reactive value handle — a cheap clone-able wrapper
around a slot in the process-wide [ReactiveGraph]. When the value
changes, every subscriber (signal-bound property binding, stateful
element with .deps([…]), derived computation) is notified.
#![allow(unused)]
fn main() {
use blinc_core::context_state::use_state;
// Auto-keyed by source location (#[track_caller]).
let count = use_state(0_i32);
// Read the current value.
let value = count.get();
// Update the value.
count.set(5);
count.update(|v| v + 1);
}
State<T> is internally an Arc-of-graph-handle, so cloning it is
cheap. Pass it by reference (&count) or by value — both work.
Signal<T> (low-level)
Inside the reactive graph the underlying slot is a Signal<T> keyed
by SignalId. State<T> is the public binding-friendly form;
Signal<T> / SignalId is what the registry indexes by. Most
application code only touches State<T>.
#![allow(unused)]
fn main() {
// Get the underlying SignalId — used by .deps([...]) on Stateful elements.
let id = count.signal_id();
}
Automatic Dependency Tracking
When code accesses a signal’s value inside an on_state callback, the
dependency is automatically recorded:
#![allow(unused)]
fn main() {
// Stateful element with signal dependency
stateful::<ButtonState>()
.deps([count.signal_id()]) // Declare dependency
.on_state(move |ctx| {
// Reading count.get() here is tracked
let value = count.get();
div().bg(color_for_value(value))
})
}
When count changes, only elements depending on it re-run their callbacks.
ReactiveGraph Internals
The ReactiveGraph manages all reactive state:
#![allow(unused)]
fn main() {
struct ReactiveGraph {
signals: SlotMap<SignalId, SignalNode>,
deriveds: SlotMap<DerivedId, DerivedNode>,
effects: SlotMap<EffectId, EffectNode>,
pending_effects: Vec<EffectId>,
batch_depth: u32,
}
}
Data Structures
| Type | Purpose |
|---|---|
SignalNode | Stores value + list of subscribers |
DerivedNode | Cached computed value + dirty flag |
EffectNode | Side-effect function + dependencies |
Subscription Flow
Signal.set(new_value)
│
├── Mark all subscribers dirty
│
├── Propagate to derived values
│
└── Queue effects for execution
Derived Values (Computed<T>)
Derived values compute from other signals and cache the result.
use_computed returns a [Computed<T>] — a binding-friendly wrapper
around a Derived<T> handle plus the reactive graph it lives in:
#![allow(unused)]
fn main() {
use blinc_core::context_state::{use_state, use_computed};
let count = use_state(0_i32);
let doubled = {
let count = count.clone();
use_computed(move |_g| count.get() * 2)
};
// Value is cached until any tracked dependency changes.
let value = doubled.get(); // Computed once
let again = doubled.get(); // Returns cached value
}
Auto-tracked dependencies
The closure passed to use_computed auto-tracks every signal it
reads. There is no .deps([…]) for computed values — touching a
State<T> inside the closure subscribes the derived to that signal.
When any tracked dependency fires .set(...), the derived’s dirty
bit flips and the PropertyBindingRegistry notifies every property
binding subscribed to that derived.
Lazy Evaluation
Derived values only compute when:
- First accessed after creation
- Accessed after a dependency changed
- Their value is explicitly needed
This prevents wasted computation for unused values.
Binding to properties
Computed<T> plugs into the same IntoReactive<T> channel as
State<T>. Pass &computed to any reactive setter:
#![allow(unused)]
fn main() {
div()
.child(text("Doubled").color(&doubled_color))
.w(&width_computed)
}
See Reactive Property Bindings in the State Management chapter for the call-site surface.
Effects
Effects are side-effects that run when dependencies change:
#![allow(unused)]
fn main() {
// Conceptual - effects
effect(|| {
let value = count.get(); // Tracks dependency on count
println!("Count changed to {}", value);
});
}
Effects are:
- Queued when dependencies change
- Executed after the current batch completes
- Run in topological order (respecting dependency depth)
Batching
Multiple signal updates can be batched to prevent redundant recomputation:
#![allow(unused)]
fn main() {
// Without batching: 3 separate updates, 3 effect runs
count.set(1);
name.set("Alice");
enabled.set(true);
// With batching: 1 combined update, 1 effect run
ctx.batch(|g| {
g.set(count, 1);
g.set(name, "Alice");
g.set(enabled, true);
});
}
How Batching Works
batch_start()increments batch depth counter- Signal updates mark subscribers dirty but don’t run effects
batch_end()decrements counter- When counter reaches 0, all pending effects execute
Property Bindings (Unified Property Channel)
A second integration path runs alongside .deps() + on_state:
signal-bound element properties. Calling
div().bg(&state) or div().w(&computed) registers a subscription
against the minted LayoutNodeId in the process-wide
PropertyBindingRegistry. When the signal (or any tracked
dependency of the computed) fires, the registry walks subscribers and
queues a [PartialPropertyUpdate] — the same channel CSS animations,
transitions, and stateful refreshes already use.
State<T>::set(new_value)
│
▼
notify_property_bindings(signal_id)
│
▼ (registry walks subscribers for signal_id)
│
├── render-targeting: queue_prop_update_partial(node, prop, write)
└── layout-targeting: queue_layout_update_partial(node, prop, write)
│
▼
platform runner drains queue next frame:
- patches RenderProps / taffy::Style
- schedules compute_layout if needs_layout = true
- request_redraw()
Two parallel indexes — one keyed by SignalId, one by DerivedId,
both feeding the same Subscriber dispatch path. Computed<T> rides
the second; State<T> rides the first.
Lifecycle
Registration happens inside the element’s build() after the layout
node is minted. Unregistration happens automatically in
remove_subtree_nodes — PropertyBindingRegistry::unregister_node
evicts every subscription belonging to that node, so a structural
rebuild can’t leak stale subscribers.
When to reach for which channel
| Goal | Channel |
|---|---|
| Update one property on one element | Reactive setter (e.g. .bg(&state)) — cheapest |
| Update many properties or restructure children | Stateful<S> + .deps([signal_id]) + on_state |
| Side effect (logging, IO) when a signal changes | Effects (future) |
See the Reactive Property Bindings section for the call-site surface.
Integration with Stateful Elements
The reactive system integrates with stateful elements via .deps():
#![allow(unused)]
fn main() {
fn counter_display(count: State<i32>) -> impl ElementBuilder {
stateful::<NoState>()
// Declare signal dependencies
.deps([count.signal_id()])
.on_state(move |_ctx| {
// This callback re-runs when count changes
let current = count.get();
div().child(text(&format!("{}", current)).color(Color::WHITE))
})
}
}
Dependency Registry
The system maintains a registry of signal dependencies:
#![allow(unused)]
fn main() {
// Internal tracking
struct DependencyEntry {
signal_ids: Vec<SignalId>,
node_id: LayoutNodeId,
refresh_callback: Box<dyn Fn()>,
}
}
When signals change, the registry triggers rebuilds for dependent nodes.
Performance Characteristics
O(1) Signal Access
Reading a signal is a simple memory lookup:
#![allow(unused)]
fn main() {
fn get(&self) -> T {
self.value.clone() // Direct access, no computation
}
}
O(subscribers) Propagation
Updates only touch direct subscribers:
#![allow(unused)]
fn main() {
fn set(&mut self, value: T) {
self.value = value;
for subscriber in &self.subscribers {
subscriber.mark_dirty();
}
}
}
Minimal Allocations
SignalIdis a 64-bit handle (Copy)- Subscriber lists use
SmallVec<[_; 4]>(inline for small counts) - SlotMap provides dense storage without gaps
Comparison to Virtual DOM
| Aspect | Virtual DOM | Blinc Reactive |
|---|---|---|
| State change | Rebuild entire component | Update only affected nodes |
| Diffing | O(tree size) | O(1) per signal |
| Memory | VDOM objects per render | Fixed signal storage |
| Dependency tracking | Manual (useEffect deps) | Automatic |
Best Practices
-
Prefer the auto-keyed form —
use_state(initial)derives a slot from the source location. Useuse_state_keyed("key", || value)only when one source line produces multiple instances (loops, repeated factories). -
Bind properties directly when you can —
div().bg(&state)/div().w(&computed)is cheaper than rebuilding a Stateful subtree for a single-property update. -
Reach for
.deps()+on_statewhen the subtree shape changes — reactive setters patch one property; restructuring children needs the callback path. -
Batch related updates — Group multiple signal changes to avoid redundant work.
-
Use
Computed<T>for multi-signal reactivity —use_computedauto-tracks every signal touched inside its closure, so a derived value bound via.opacity(&computed)fires when any of its inputs change. -
Keep signals granular — Fine-grained signals enable more precise updates. A separate
State<f32>forwidthandopacityre-paints less than a singleState<UiConfig>carrying both.