lazily 0.4.0

Lazy reactive signals with dependency tracking and cache invalidation
Documentation

lazily

Lazy reactive primitives for Rust — Context, Slots, Cells with automatic dependency tracking and cache invalidation.

crates.io

Overview

lazily provides four core primitives for lazy reactive computation:

  • Context — owns all reactive state and manages the dependency graph
  • Slot — a lazily-computed cached value that automatically tracks dependencies
  • Cell — a mutable value that invalidates dependent Slots when changed
  • Effect — a side-effect callback that automatically reruns after tracked dependencies invalidate

Values are lazy: dependents are marked dirty on invalidation but only validated or recomputed when accessed. This contrasts with eager "signal" systems that recompute immediately. ctx.memo() Slots use a memo guard: if recomputation produces the same value, downstream dirty caches and effects are left alone. Multiple updates can be grouped with ctx.batch(...) so invalidation and effect reruns happen once after the outermost batch exits.

Usage

use lazily::Context;

let ctx = Context::new();

// Create a mutable cell
let counter = ctx.cell(0i32);

// Create a derived value (automatically tracks dependencies)
let doubled = ctx.computed(|ctx| {
    let val = ctx.get_cell(&counter);
    val * 2
});

assert_eq!(ctx.get(&doubled), 0);

// Mutate the cell — dependents are marked dirty (not recomputed yet)
ctx.set_cell(&counter, 5);

// Slot recomputes lazily on next access
assert_eq!(ctx.get(&doubled), 10);

// Effects run immediately and then after tracked dependencies change
let effect = ctx.effect(move |ctx| {
    println!("counter = {}", ctx.get_cell(&counter));
});

ctx.set_cell(&counter, 6); // schedules and runs the effect once
effect.dispose(&ctx); // unsubscribes and prevents future reruns

// Batch writes coalesce invalidation and effect reruns.
ctx.batch(|ctx| {
    ctx.set_cell(&counter, 7);
    ctx.set_cell(&counter, 8);
});

Why Lazy?

Lazy (Slots) Eager (Signals)
When does recomputation happen? On access (get) Immediately on change
Wasted work Zero — only compute what's read Can compute values nobody uses
Glitch-free By construction Requires topological sorting
Ordering Irrelevant — pull-based Critical — push-based DAG walk
Use case Request handling, data pipelines UI rendering, real-time updates

In a web server handling requests, you might have 50 computed values available but any given request only uses 5. With eager reactivity, all 50 recompute on every change. With lazy, only the 5 actually accessed compute.

Core Concepts

Context

Context owns all Slots and Cells. It manages the dependency graph and provides the API for creating, reading, and mutating reactive values. Think of it as the "world" for your reactive computations — in web frameworks, this maps to a request context, application scope, or component tree.

The current Context is intentionally single-threaded. It uses RefCell and non-Send callback storage to keep the fast path allocation-only and mutex-free. Create independent contexts per OS thread for local graphs, or use ThreadSafeContext when one reactive graph must be shared across threads.

Slot

A SlotHandle<T> wraps a compute function Fn(&Context) -> T. The result is cached after first access. Dependencies are discovered automatically via a thread-local tracking stack — any Slot or Cell accessed during computation becomes a dependency. ctx.computed() is the ergonomic name for a derived value; ctx.slot() is the same primitive. Use ctx.memo() when T: PartialEq and equal recomputations should suppress downstream work.

When a dependency is invalidated, the Slot marks its cached value dirty. It does not validate or recompute until ctx.get() is called again. For ctx.memo() slots, if recomputation returns a value equal to the previous cache, downstream dirty Slots become fresh without recomputing, and scheduled effects that only depended on unchanged Slots skip cleanup/rerun.

Dependencies are dynamic. Every time a Slot recomputes, it re-discovers its dependencies from scratch. If your compute function has conditional branches that access different Cells depending on state, the dependency graph updates automatically. No stale subscriptions, no manual cleanup.

Cell

A CellHandle<T> holds a mutable value. ctx.set_cell() compares old and new values via PartialEq — if unchanged, no invalidation occurs. If changed, all dependent Slots are recursively marked dirty.

Batch Updates

ctx.batch(|ctx| { ... }) groups multiple cell updates and explicit slot/cell clears into one invalidation pass. Nested batches flush only when the outermost batch exits. Direct ctx.get_cell() reads inside the callback see the latest cell value immediately; changed-cell dependents are marked dirty after the batch, so Slot reads during the callback return their pre-batch cached value until the batch completes.

Effect

An EffectHandle represents a side-effect callback registered with ctx.effect(). Effects run immediately, track any Slots or Cells read during that run, and rerun after those dependencies invalidate. Scheduled effect reruns are flushed after the invalidation pass, so diamond dependency paths coalesce to one rerun. Effects scheduled only by dirty Slot dependencies first validate those Slots and skip cleanup/rerun when values are unchanged.

Effects can return a cleanup closure. Cleanup runs before the next rerun and when the handle is disposed:

let effect = ctx.effect(move |ctx| {
    let value = ctx.get_cell(&counter);
    move || println!("cleanup for {value}")
});

effect.dispose(&ctx);

API

Method Purpose
Context::new() Create a new context
ctx.computed(|ctx| T) Create a derived lazily-computed value
ctx.slot(|ctx| T) Create a lazily-computed slot; synonym of ctx.computed()
ctx.memo(|ctx| T) Create a lazily-computed slot with a PartialEq memoization guard
ctx.get(&slot) Get value (computes if unset)
ctx.cell(value) Create a mutable cell
ctx.get_cell(&cell) Get cell value
ctx.set_cell(&cell, value) Update cell (marks dependents dirty if changed)
ctx.batch(|ctx| { ... }) Defer changed-cell dirty marking and explicit clears until the outermost batch exits
ctx.effect(|ctx| { ... }) Run an effect immediately and rerun it after tracked dependencies invalidate
ctx.is_set(&slot) Check if slot has a cached, fresh value
slot.clear(&ctx) Clear cached value and cascade to dependents
cell.clear_dependents(&ctx) Clear downstream slots without changing cell value
effect.dispose(&ctx) Dispose an effect and unsubscribe dependencies
effect.is_active(&ctx) Check whether an effect is still registered

ThreadSafeContext

ThreadSafeContext is the mutex-backed counterpart for sharing one reactive graph across OS threads. It mirrors the core Context methods while requiring Send + Sync + 'static values and compute/effect callbacks. The graph lock is released before user compute callbacks, effect callbacks, or cleanup closures run, so callbacks can re-enter the same context without deadlocking. If a slot is invalidated while its callback is running, the stale result is discarded and the getter retries before returning a fresh value.

Design

  • Lazy, not eager: Slots mark dirty on invalidation but only validate/recompute on access
  • Ergonomic aliases: ctx.computed() names derived values while preserving ctx.slot() for low-level terminology
  • PartialEq guard: Cell.set() only invalidates when value actually changes
  • Memo guard: Dirty ctx.memo() Slots compare recomputed values and suppress downstream recomputation/effect reruns when values are equal
  • Dynamic dependencies: Edges re-discovered on each recomputation (no stale subscriptions)
  • Batching: Multiple writes share one invalidation/effect flush boundary
  • Effect scheduling: Effects rerun after dependency invalidation and coalesce duplicate schedules
  • Interior mutability via RefCell (single-threaded)
  • Thread-local tracking stack for automatic dependency discovery
  • Zero external dependencies

Threading Roadmap

lazily-rs guarantees local, single-threaded Context graphs plus an explicit ThreadSafeContext for shared graphs. SlotHandle<T> and CellHandle<T> are Send + Sync when T is Send + Sync, and EffectHandle is also Send + Sync, but handles must be used with their owning context.

Enable the optional tokio feature for sync-on-Tokio integration tests and the tokio_sync example:

cargo test --features tokio
cargo run --example tokio_sync --features tokio

The feature proves ThreadSafeContext can be shared through tokio::spawn and tokio::task::spawn_blocking. It does not add async computations or effects; those need the separate AsyncContext design captured in SPEC.md, including in-flight future deduplication, stale completion handling, cleanup ordering, and separate Send versus LocalSet surfaces.

Multi-Language

lazily is implemented across three languages with shared semantics:

lazily-rs lazily-zig lazily-py
Context Owned Context struct Explicit allocator Plain dict
Slot creation Box<dyn Fn> closures comptime function pointers Lambdas
Cell equality PartialEq trait std.meta.eql != operator
Thread safety Single-threaded Context; explicit ThreadSafeContext Mutex by default GIL
Storage Unified generics .direct / .indirect Object identity

Related

License

MIT