Keyboard shortcuts

Press or to navigate between chapters

Press ? to show this help

Press Esc to hide this help

Reactive System Architecture

Rinch uses a fine-grained reactivity model inspired by Solid.js and Leptos. This document describes the architecture and design decisions.

Core Concepts

Signals

A Signal is a reactive container that holds a value and notifies subscribers when it changes.

#![allow(unused)]
fn main() {
let count = Signal::new(0);

// Read the value
let value = count.get();

// Update the value (triggers subscribers)
count.set(5);

// Update based on current value
count.update(|n| *n += 1);
}

Key properties:

  • Reading a signal inside an effect automatically subscribes to it
  • Setting a signal schedules dependent effects to re-run
  • Signals are Clone and can be shared across closures

Effects

An Effect is a side-effect that re-runs when its dependencies change.

#![allow(unused)]
fn main() {
let count = Signal::new(0);

Effect::new(move || {
    println!("Count is now: {}", count.get());
});

count.set(1); // Prints: "Count is now: 1"
count.set(2); // Prints: "Count is now: 2"
}

Key properties:

  • Dependencies are tracked automatically (no dependency arrays)
  • Effects run immediately when created, then re-run when dependencies change
  • Effects are cleaned up when their scope is disposed

Memos

A Memo is a cached computed value that only recomputes when dependencies change.

#![allow(unused)]
fn main() {
let count = Signal::new(2);
let doubled = Memo::new(move || count.get() * 2);

doubled.get(); // Returns 4
count.set(3);
doubled.get(); // Returns 6 (recomputed)
doubled.get(); // Returns 6 (cached)
}

Key properties:

  • Lazily evaluated (only computes when read)
  • Caches the result until dependencies change
  • Can be read inside effects (creates a subscription)

Dependency Tracking

Rinch uses automatic dependency tracking at runtime:

  1. When an effect runs, it registers itself as the “current observer”
  2. When a signal is read, it checks for a current observer and subscribes it
  3. When a signal changes, it notifies all subscribers to re-run
┌─────────────┐     read      ┌─────────────┐
│   Effect    │ ───────────── │   Signal    │
│             │               │             │
│  observer   │ ◄──subscribe──│ subscribers │
└─────────────┘               └─────────────┘
                                    │
                                    │ set()
                                    ▼
                              notify observers

Runtime Architecture

┌────────────────────────────────────────────────────┐
│                    Runtime                          │
├────────────────────────────────────────────────────┤
│  ┌──────────────┐  ┌──────────────┐               │
│  │ Observer     │  │ Pending      │               │
│  │ Stack        │  │ Effects      │               │
│  └──────────────┘  └──────────────┘               │
│                                                    │
│  ┌──────────────────────────────────────────────┐ │
│  │              Signal Storage                   │ │
│  │  ┌────────┐ ┌────────┐ ┌────────┐           │ │
│  │  │Signal 1│ │Signal 2│ │Signal 3│  ...      │ │
│  │  └────────┘ └────────┘ └────────┘           │ │
│  └──────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────┘

Observer Stack

The runtime maintains a stack of “current observers”:

  • When an effect starts, it pushes itself onto the stack
  • When a signal is read, it subscribes the top of the stack
  • When an effect ends, it pops itself

This allows nested effects to work correctly.

Batching

Multiple signal updates can be batched to avoid redundant effect runs:

#![allow(unused)]
fn main() {
batch(|| {
    count.set(1);
    name.set("Alice");
    // Effects only run once, after the batch
});
}

Scheduling

Effects are scheduled to run after the current synchronous code completes:

  1. Signal is set → effect is marked as “dirty”
  2. Dirty effects are queued for execution
  3. After current execution, queued effects run
  4. This prevents infinite loops and ensures consistent state

Memory Management

Scopes

A Scope manages the lifetime of reactive primitives. In practice, RenderScope serves as the scope for component rendering:

#![allow(unused)]
fn main() {
// RenderScope is the scope used during rendering.
// Effects created via __scope.create_effect() are tracked
// and disposed when the scope is dropped.

fn my_component(__scope: &mut RenderScope) -> NodeHandle {
    let signal = Signal::new(0);
    __scope.create_effect(|| { /* tracked by this scope */ });
    // signal and effect belong to this scope
    rsx! { div { } }
}
// When __scope is disposed, all its effects are cleaned up
}

Note: Scope::new() and Scope::run() exist in the codebase but are currently placeholders. The active scope mechanism is RenderScope, which tracks effects and child scopes for automatic cleanup.

Ownership

  • Signals are reference-counted (Rc<RefCell<T>>)
  • Effects hold strong references to their closures
  • Disposing a scope drops all its primitives

Integration with UI

The reactive system integrates with the rendering pipeline:

  1. Component functions run inside a RenderScope
  2. RSX expressions use closure syntax for reactive reads: {|| count.get().to_string()}
  3. When signals change, Effects re-run and surgically update affected DOM nodes
  4. Fine-grained updates - only the specific nodes bound to changed signals are updated
#![allow(unused)]
fn main() {
#[component]
fn counter() -> NodeHandle {
    let count = Signal::new(0);

    rsx! {
        div {
            // Reactive text - closure syntax {|| ...} creates an Effect
            "Count: " {|| count.get().to_string()}

            button { onclick: move || count.update(|n| *n += 1),
                "Increment"
            }
        }
    }
}
}

Note: The closure syntax {|| expr} is required for reactive updates — without it, values are captured once at initial render and never update.

Thread Safety

The current implementation uses Rc<RefCell<T>> for single-threaded use:

  • Thread-local runtime by default
  • All reactive primitives (Signal, Effect, Memo) are !Send and !Sync
  • This is intentional: GUI frameworks are inherently single-threaded (main thread)
  • The observer stack and effect scheduling are thread-local

Comparison with Other Systems

FeatureRinchReactSolid.jsLeptos
ReactivityFine-grainedCoarse (VDOM)Fine-grainedFine-grained
TrackingAutomaticManual (deps array)AutomaticAutomatic
SchedulingBatchedBatchedSynchronousBatched
MemoryScopedGCScopedScoped