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.
use graph = new Graph ()
use _ = graph.Activate ()
let price = createAsyncSource<int> ()
let total = createMemo (fun _ -> price.Value * 3)
let view =
createBoundary
(fun _ -> "Loading…")
(fun ex _ -> "Unavailable: " + ex.Message)
(fun () -> sprintf "Total %d" total.Value)
// view: Fallback
price.Settle 4
price.Fail (exn "feed offline")
price.Settle 5using 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);- Fallback
Ready "Loading…"IsWaiting = true - Ready
Ready "Total 12"IsWaiting = false - Recovered
Ready "Unavailable: feed offline"Caught ≠ null - Ready
Ready "Total 15"Caught = null
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
The same log answers questions a call stack cannot. An untraced build compiles it out, IL for IL.
Trace.why Why did this not run?Trace.whyNot What did each run do?Trace.history What is it waiting on?Trace.waitingOn Where did this node come from?Trace.origin What does the graph look like?Trace.snapshotWhy Ranvier
The parts of reactive state that .NET developers most often rebuild by hand, built into the library.
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.
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.
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.
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.
Derived reads settle both paths from a shared source before combining them. Use batch when several source writes belong to one update.
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.
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 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.
ManualDispatcher and Settle let a test decide when each flight lands. Loading, failure and cancellation are tested with no timers, sleeps or polling.
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.
Ranvier.Elmish keeps an existing init and update and reads the model through selector memos. Editable values cover forms seeded from upstream 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.