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
Signals
A Signal<T> is a reactive container for a value. When the value changes, all dependent computations automatically update.
#![allow(unused)]
fn main() {
// Create a signal
let count = ctx.use_state_keyed("count", || 0i32);
// Read the current value
let value = count.get();
// Update the value
count.set(5);
count.update(|v| v + 1);
}
Signal IDs
Signals are identified by SignalId, a cheap-to-copy handle:
#![allow(unused)]
fn main() {
// Get the signal's ID for dependency tracking
let id = count.signal_id();
}
Automatic Dependency Tracking
When code accesses a signal’s value, the dependency is automatically recorded:
#![allow(unused)]
fn main() {
// Stateful element with signal dependency
stateful(handle)
.deps(&[count.signal_id()]) // Declare dependency
.on_state(move |state, div| {
// Reading count.get() here is tracked
let value = count.get();
div.set_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
Derived values compute from other signals and cache the result:
#![allow(unused)]
fn main() {
// Conceptual - derived values
let doubled = derived(|| count.get() * 2);
// Value is cached until count changes
let value = doubled.get(); // Computed once
let again = doubled.get(); // Returns cached value
}
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.
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
Integration with Stateful Elements
The reactive system integrates with stateful elements via .deps():
#![allow(unused)]
fn main() {
fn counter_display(ctx: &WindowedContext, count: State<i32>) -> impl ElementBuilder {
let handle = ctx.use_state(ButtonState::Idle);
stateful(handle)
// Declare signal dependencies
.deps(&[count.signal_id()])
.on_state(move |_state, container| {
// This callback re-runs when count changes
let current = count.get();
container.merge(
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
-
Use keyed state for persistence -
use_state_keyed("key", || value)survives rebuilds -
Batch related updates - Group multiple signal changes to avoid redundant work
-
Declare dependencies explicitly - Use
.deps()for stateful elements that read signals -
Prefer stateful for visual changes - Use stateful elements instead of signals for hover/press effects
-
Keep signals granular - Fine-grained signals enable more precise updates