Skip to content
Preview APIs may change.

ranvier

Fine-grained reactive computation for .NET.

Signals, memos and effects where loading and failure are part of the graph. Pending and failed states propagate through dependent Value reads to a boundary, so loading UI can be derived from the graph. Glitch-free, owned and inspired by Solid.
A traced build lets people and agents ask why anything ran; an untraced release build compiles the tracing out.

using var graph = new Graph();
using var _ = graph.Activate();

var price = AsyncSource<int>();
var total = Memo(() => price.Value * 3);
var view = Boundary(
    () => $"Total {total.Value}",
    () => "Loading…",
    ex => $"Unavailable: {ex.Message}");

// view: Fallback
price.Settle(4);
price.Fail(new Exception("feed offline"));
price.Settle(5);
view.TryValue
  1. FallbackReady "Loading…"IsWaiting = true
  2. ReadyReady "Total 12"IsWaiting = false
  3. RecoveredReady "Unavailable: feed offline"Caught ≠ null
  4. ReadyReady "Total 15"Caught = null
Output from running this code against Ranvier ad2d84d.

States you can name

Every reader observes one of six states. The docs, the marks and the API use the same names. Hover a state to hold the map in it.

Watch the graph think

A cart changes faster than its shipping service can answer. Watch debounce hold a request until the edits stop, trailing throttle refresh a preview at its fixed deadline, and a superseded quote drop. Switch to pickup and the total stops reading shipping: that edge disappears.

This runs the real engine compiled to JavaScript with tracing on. Start with Rapid cart edits, then advance the example clock by 20 ms and 80 ms. The preview moves first; shipping waits for quiet. Fail the quote, switch to pickup, then retry delivery and settle it. Hover a node for its state; click it for why it last ran. Play, Step and the scrub bar replay your recorded events. How to read a map.

Example code
let graph = Graph.Current
let clock = MapClock()
let options = { Clock = clock; Comparer = None }
let interval = System.TimeSpan.FromMilliseconds 100.
let desk = Desk<int>()
let qty = createSignal 1
let pickup = createSignal false
let subtotal = createMemo (fun _ -> 4 * qty.Value)
let admitted = debounceWith options interval (fun () -> subtotal.Value) graph
let preview = throttleLastWith options interval (fun () -> subtotal.Value) graph
let shipping = createAsync (fun _ _ -> desk.Quote admitted.Value)
let total =
    createMemo (fun _ ->
        subtotal.Value + (if pickup.Value then 0 else shipping.Value))

let view =
    createBoundary
        (fun _ -> "Loading…")
        (fun _ _ -> "Quote offline")
        (fun () -> sprintf "Total %d" total.Value)

createEffect (fun () -> printfn "%s / preview %d" view.Value preview.Value)

controls [
    button "Rapid cart edits" (fun () ->
        qty.Value <- 2
        clock.Advance 40.
        qty.Value <- 3
        clock.Advance 40.
        qty.Value <- 4)
    |> describe "Three captures. Both timed outputs still publish 4; shipping has one request."
    |> expect "captures do not start shipping requests" (fun () -> admitted.Peek = 4 && preview.Peek = 4 && shipping.Runs = 1)
    button "Advance 20 ms" (fun () -> clock.Advance 20.)
    |> describe "At 100 ms the preview publishes 16. Debounce's quiet period ends at 180 ms."
    |> expect "throttle moves before debounce" (fun () -> preview.Peek = 16 && admitted.Peek = 4 && shipping.Runs = 1)
    button "Advance 80 ms" (fun () -> clock.Advance 80.)
    |> describe "Debounce admits 16. A new shipping flight supersedes the initial quote."
    |> expect "one new request follows the burst" (fun () -> admitted.Peek = 16 && shipping.Runs = 2 && desk.Pending = 1)
    button "Fail quote" (fun () -> desk.Fail "service offline")
    |> describe "The boundary turns the shipping failure into a visible fallback."
    |> expect "failure reaches the boundary" (fun () -> view.Peek = "Quote offline")
    toggleSignal "Pickup" pickup [ true ]
    |> describe "Pickup skips shipping. The dependency edge disappears and the total recovers."
    |> expect "pickup ignores the failed quote" (fun () -> view.Peek = "Total 16")
    button "Retry delivery" (fun () ->
        pickup.Value <- false
        qty.Value <- 5
        clock.Advance 100.)
    |> describe "Delivery reads shipping again. A changed cart starts a fresh quote after quiet."
    |> expect "delivery waits for the new quote" (fun () -> not pickup.Peek && admitted.Peek = 20 && view.Peek = "Loading…" && desk.Pending = 1)
    button "Settle quote" (fun () -> desk.Settle 5)
    |> describe "The latest shipping quote settles. The boundary publishes the delivered total."
    |> expect "delivery recovers" (fun () -> view.Peek = "Total 25" && preview.Peek = 20)
]

Explore debounce and throttle · Build your own map in the browser

Why Ranvier

The parts of reactive state that .NET developers most often rebuild by hand, built into the library.

Loading and errors, derived

Pending and failure travel on their own channel. They pass from an async source through every memo that reads it to the nearest boundary, which shows a fallback or a recovered value. A flight policy decides what happens when inputs change during a request: cancel the flight, keep only the latest, queue it, finish it and run once more, or discard the change. During a refresh Peek keeps the last value.

Recoverable error state

A failed node stores an exception; a boundary can show a recovered value. Dependencies read during the failed run remain tracked, and a later successful run clears the failure. ErrorOrigin names the node it started in.

Owned lifetimes

Memos, effects and projection rows belong to owners. Disposing an owner releases child nodes and cleanups together in reverse acquisition order. Register external resources with onCleanup, and dispose the enclosing scope when its lifetime ends.

One source, one target

bindSignal and bindMemo copy a fixed source into a target without tracking reads in the writer. Bindings batch with the graph and stop when their owner is disposed. Use an effect when dependencies need to change.

Glitch-free diamonds

Derived reads settle both paths from a shared source before combining them. Use batch when several source writes belong to one update.

Built for C# and XAML

ReactiveBindings raises PropertyChanged for derived properties with no dependency attributes. ReactiveCommand derives CanExecute and IsRunning from the graph. C# callers need no F# option types.

Incremental collections

Editable keyed sources update rows directly. Map membership consumes key deltas; value readers report unequal settled rows. A bound ObservableCollection applies individual changes after population, with resets for initialization and recovery. The guide documents which view paths still scan or sort keys.

A written threading contract

A graph checks that it is called from its own thread, and the contract lists which calls may come from other threads. Serialised mode accepts a Blazor Server circuit's changing threads and raises on genuine concurrency.

Deterministic async tests

ManualDispatcher and Settle let a test decide when each flight lands. Loading, failure and cancellation are tested with no timers, sleeps or polling.

Portable, with no platform package

One core for net10.0 and netstandard2.1, with no UI-framework dependency. CI checks traced and untraced Native AOT and trimming on Linux with .NET 10. Package builds include Fable sources for JavaScript consumers.

Adopt it one view at a time

Ranvier.Elmish keeps an existing init and update and reads the model through selector memos. Editable values cover forms seeded from upstream data.

Shared remote data

Ranvier.Query shares acquired queries across pages, retains accepted data during refresh and reconciles mutation receipts across loaded queries in one batch. Loading is explicit; reads start no IO.