Skip to content

C#

Create signals, derive values and react to changes from C# with Ranvier.CSharp.

The Reactive factories take familiar Func and Action delegates. They return the engine's own nodes, including Signal<T>, Memo<T>, AsyncMemo<T> and Projection<K, V>. Extension methods provide Graph.Run, Signal.Update and the collection operators.

At a glance

A city drives an async temperature request; a computed property formats the result for a XAML view. ReactiveObject handles property notifications, loading and errors.

A complete weather view model
public interface IWeatherService
{
    Task<int> TemperatureAsync(string city, CancellationToken token);
}

public sealed class WeatherViewModel : ReactiveObject
{
    readonly BoundSignal<string> city;
    readonly Signal<int> attempt;
    readonly BoundValue<string> forecast;

    public WeatherViewModel(Graph graph, IWeatherService weather) : base(graph)
    {
        city = Bindings.Writable(nameof(City), "Oslo");
        attempt = Bindings.Run(() => Signal(0));
        var celsius = Bindings.Run(() => Async(token =>
        {
            _ = attempt.Value;
            return weather.TemperatureAsync(city.Value, token);
        }));
        forecast = Bindings.Computed(nameof(Forecast), () => $"{City}: {celsius.Value} °C");
    }

    public string City { get => city.Value; set => city.Value = value; }
    public string Forecast => forecast.Value;
    public void Retry() => attempt.Value++;
}

Bound to a view, the example behaves as follows:

  • Loading. While the temperature is in flight, IsLoading is true and Forecast keeps its last value, or null before the first result. Setting City starts a new request.
  • Value. When the request completes, Forecast raises PropertyChanged, then IsLoading turns false.
  • Error. When the request fails, HasErrors turns true, ErrorsChanged is raised for Forecast, and GetErrors("Forecast") returns the exception's message. Forecast still shows the last value.
  • Retry. Retry writes the signal the request reads, so the request runs again for the current city. The error clears while it loads, and the next result replaces it.

ReactiveObject implements INotifyPropertyChanged, INotifyDataErrorInfo and IDisposable, and exposes IsLoading and HasErrors. See Binding to XAML for the details.

Runnable sample

The sample runs as a test in tests/Ranvier.CSharp.Tests/HeadlineSampleTests.cs.

Setup

Reference Ranvier.CSharp; it brings Ranvier with it:

dotnet add package Ranvier.CSharp --prerelease
Reference a source build
<ProjectReference Include="path/to/Ranvier/src/Ranvier.CSharp/Ranvier.CSharp.fsproj" />

Import the factories statically:

using Ranvier;
using Ranvier.CSharp;
using static Ranvier.CSharp.Reactive;

A first graph

A signal holds a value, a memo derives one, and an effect reacts to changes in what it reads.

Test your understanding

What does the effect print on construction? What does it print when count changes to 2?

var graph = new Graph();

graph.Run(() =>
{
    var count = Signal(1);
    var doubled = Memo(() => count.Value * 2);
    Effect(() => Console.WriteLine($"doubled {doubled.Value}"));

    count.Value = 2;
});
Answer
doubled 2
doubled 4

The first read computes doubled from 1. The write refreshes it from 2 and runs the effect again before the write returns.

The map runs the same engine behaviour as the C# example. Set the count to 2 and watch the derived value update before the effect prints it.

The factories

Use Signal for writable state, Memo for derived state, and Effect for side effects. The remaining factories cover async values, boundaries, scopes and collections.

C# factories and their F# equivalents
C#F#
Signal(initial)createSignal
Memo(() => …)createMemo
Memo(previous => …, seed)createMemo, with the previous value or seed
OwningMemo(() => …)createMemoWith
Editable(() => …), Draft(() => …)createEditable, createDraft
Effect(() => …)createEffect, returning the Effect: dispose it to stop the effect early
EffectOn(() => …, value => …)createEffectOn
Async(token => …), Async((previous, token) => …)createAsync
OwningAsync(token => …)createAsyncWith
AsyncSource<T>()createAsyncSource
Suspense(body, fallback), Suspense(body, previous => …, seed)createSuspense
ErrorBoundary(body, error => …), ErrorBoundary(body, (error, previous) => …, seed)createErrorBoundary
Boundary(body, fallback, error => …), Boundary(body, previous => …, (error, previous) => …, seed)createBoundary
Batch, Untrack, OnCleanup, Flushbatch, untrack, onCleanup, flush
Root(owner => …)createRoot
CurrentOwner, RunWithOwner(owner, …)getOwner, runWithOwner
Projection(source, keyOf, map)createProjection
IndexProjection(source, map)createIndexProjection
Lookup(source, f, affected), Selector(source)createLookup, createSelector
Construct a boundary with an explicit graph

Boundary<T>.Suspense, Errors and Catching take the graph explicitly, with the seed before the handlers.

Async values

Async takes a task factory. A new request cancels the token of the request it supersedes.

A boundary turns loading, success and failure into values the view can display.

A search view model

Query drives the request. Summary displays its results, a loading message or an error message. IsSearching and Error read the boundary's state.

public interface ISearchService
{
    Task<IReadOnlyList<string>> SearchAsync(string query, CancellationToken token);
}

public sealed class SearchViewModel
{
    public SearchViewModel(ISearchService service)
    {
        Query = Signal("");
        Results = Async(token => service.SearchAsync(Query.Value, token));
        Summary = Boundary(
            () => Results.Value.Count == 0 ? "No matches" : string.Join(", ", Results.Value),
            () => "Searching…",
            error => $"Search failed: {error.Message}");
    }

    public Signal<string> Query { get; }
    public AsyncMemo<IReadOnlyList<string>> Results { get; }
    public Boundary<string> Summary { get; }
    public bool IsSearching => Summary.IsWaiting;
    public Exception? Error => Summary.Caught;
}

Construct it inside graph.Run, and bind the view with an effect:

var graph = new Graph();
var search = graph.Run(() => new SearchViewModel(service));
graph.Run(() => Effect(() => Console.WriteLine(search.Summary.Value)));

search.Query.Value = "ada";

With the effect observing Summary:

  • A write to Query starts a search and cancels the previous request's token.
  • While loading, the effect prints Searching… and IsSearching is true.
  • A failure prints Search failed: … and sets Error.
  • A later success prints the results and clears Error.
Use the previous async value

The overload taking Previous<T> receives the value last published:

  • previous.SettledOr(seed) returns it, or the seed before the first value.
  • previous.TrySettled() returns (HasValue, Value).
var total = Async<int>(async (previous, token) =>
{
    var by = step.Value;
    return await previous.SettledOr(0) + by;
});

Both return a ValueTask that is already complete under CancelPrevious and KeepLatest. Under Queue it completes once the flight started before this one is applied.

Async completion and threads

A request that completes on the thread pool reaches the graph through its dispatcher. See Async and pending.

Read without throwing

TryValue lets you handle ready, failed and pending states explicitly. Use TryGetValue and TryGetError to take the result apart:

if (price.TryValue.TryGetValue(out var value)) Console.WriteLine(value);
else if (price.TryValue.TryGetError(out var error)) Console.WriteLine(error.Message);
else Console.WriteLine("pending");

Collections

Projection operators use LINQ names and return live nodes that update per changed row.

var rows = Projection(() => todos.Value, t => t.Id, t => t);

var open = rows.Where(t => !t.Done).OrderBy(t => t.Title);
var hours = rows.Sum(t => t.Hours);
var remaining = rows.Count(t => !t.Done);
var firstPage = open.Take(() => pageSize.Value);
Collection operators and their F# equivalents
C#F#
Where, Select, OrderBy, GroupByProjection.filter, map, sortBy, groupBy
Take, Skip, SliceProjection.take, skip, sub
Sum for int, long, decimal and doubleProjection.sumBy
Count, Any, AllProjection.countBy, exists, forall
Aggregate(seed, folder)Projection.fold
Aggregate(zero, add, subtract)Projection.foldGroup

Use rows.TryGetValue(key, out var row) for a row that may be absent. Lookup supports the same method. Use AsObservableCollection to bind a projection to a WPF, Avalonia or MAUI list.

Read collection changes directly

rows.NewKeyReader() returns a disposable reader. Each Read() reports the keys added, removed or replaced since the previous read:

using var reader = rows.NewKeyReader();
reader.Read(); // the first read reports a reset: rebuild from Keys

var delta = reader.Read();
foreach (var (key, change) in delta.Changes)
{
    switch (change)
    {
        case KeyChange.Added: /* insert key */ break;
        case KeyChange.Removed: /* drop key */ break;
        case KeyChange.Replaced: /* rebuild key */ break;
    }
}

delta.IsReset asks for a rebuild from delta.Keys, and delta.Positional lists the index edits from PreviousKeys to Keys.

Binding to XAML

ReactiveBindings connects graph nodes to view-model property and error notifications.

  • Writable registers a two-way property backed by a signal.
  • Computed registers a read-only property backed by a tracked Func<T>.

A property raises PropertyChanged once per settled change. An equal derived result raises nothing, and derived properties track their dependencies automatically.

For a new view model, derive from ReactiveObject. Its Bindings implement INotifyPropertyChanged, INotifyDataErrorInfo and IDisposable for you.

Add bindings to an existing view model

Keep its base class. Construct ReactiveBindings with the view model as the event sender, then forward the events:

public sealed class OrderViewModel : ObservableObject, INotifyDataErrorInfo, IDisposable
{
    readonly ReactiveBindings bindings;
    readonly BoundSignal<int> quantity;
    readonly BoundValue<decimal> price;
    readonly BoundValue<decimal> total;

    public OrderViewModel(Graph graph, IPriceService prices)
    {
        bindings = new ReactiveBindings(this, graph);
        bindings.PropertyChanged += (_, e) => OnPropertyChanged(e);
        bindings.ErrorsChanged += (_, e) => ErrorsChanged?.Invoke(this, e);

        quantity = bindings.Writable(nameof(Quantity), 1);
        var quote = bindings.Run(() => Async(token => prices.QuoteAsync(quantity.Value, token)));
        price = bindings.Computed(nameof(Price), () => quote.Value);
        total = bindings.Computed(nameof(Total), () => price.Memo.Value * quantity.Value);
    }

    public int Quantity { get => quantity.Value; set => quantity.Value = value; }
    public decimal Price => price.Value;
    public decimal Total => total.Value;
    public bool IsLoading => bindings.IsLoading;

    public event EventHandler<DataErrorsChangedEventArgs>? ErrorsChanged;
    public bool HasErrors => bindings.HasErrors;
    public IEnumerable GetErrors(string? propertyName) => bindings.GetErrors(propertyName!);
    public void Dispose() => bindings.Dispose();
}

Loading and errors

While a computed property is loading, it keeps its last settled value (default before the first) and its IsLoading is true. On failure, Error holds the exception and GetErrors returns its message.

The bindings' IsLoading and HasErrors cover all properties and raise notifications under those names. Pass loadingName to Computed(name, compute, loadingName) for a per-property loading notification too.

Threads and notifications

The notifying effect runs on the graph's thread. Each event handler runs on the SynchronizationContext captured when it subscribed; notifications are posted there when raised elsewhere.

From another thread, a bound property's Value returns the value last notified. Setting a Writable property goes through Graph.Dispatch.

What disposal owns

The bindings create a root under the scope current at construction. Disposing the bindings or that scope disposes the property memos, commands and anything created through bindings.Run, and removes all handlers. Signals passed to Writable stay usable.

Commands

bindings.Command returns a ReactiveCommand that implements ICommand. Its eligibility tracks the state read by its predicate; its busy state is a signal.

An editor with Save and Load commands

Each command is disabled while the other runs. IsBusy combines both commands' running state.

public sealed class EditorViewModel : ReactiveObject
{
    readonly BoundSignal<string> draft;
    readonly BoundValue<bool> isValid;
    readonly BoundValue<bool> isBusy;

    public EditorViewModel(Graph graph, IRepository repo) : base(graph)
    {
        draft = Bindings.Writable(nameof(Draft), "");
        isValid = Bindings.Computed(nameof(IsValid), () => Draft.Length > 0);
        Save = Bindings.Command((_, token) => repo.SaveAsync(draft.Value, token), () => IsValid && Load is { IsRunning: false });
        Load = Bindings.Command((_, token) => repo.LoadAsync(token), () => !Save.IsRunning);
        isBusy = Bindings.Computed(nameof(IsBusy), () => Save.IsRunning || Load.IsRunning);
    }

    public string Draft { get => draft.Value; set => draft.Value = value; }
    public bool IsValid => isValid.Value;
    public bool IsBusy => isBusy.Value;
    public ReactiveCommand Save { get; }
    public ReactiveCommand Load { get; }
}

Eligibility and state

CanExecute is true when the predicate permits execution. A pending or failed read makes it false. Under the default CommandPolicy.Disable, a running execution also makes it false.

CanRun, IsRunning and Error raise PropertyChanged. On the graph's thread, reading them in a computed property or another command's predicate tracks them. Enabled is the memo behind CanRun.

Predicates that refer to a later command

In the example, Save reads Load, which is constructed after it. A command first evaluates its predicate on the first CanExecute call, event subscription or execution, after this constructor has returned. The pattern Load is { IsRunning: false } satisfies C# null analysis.

Execution and cancellation

ExecuteAsync(parameter) marshals to the graph's thread and starts an enabled command. Its task completes after the execution finishes and IsRunning and Error have been updated.

  • CommandPolicy.Disable disables the command before its body starts, so a second click does nothing.
  • CommandPolicy.CancelPrevious keeps it enabled. A new execution cancels the previous token; only the latest execution sets Error.

The body runs untracked. Cancel, Dispose and CancelPrevious cancel its token.

Commands on serialised graphs

Under ThreadAffinity.Serialised, Execute and ExecuteAsync start inline only on the thread inside the graph. Other calls, including button handlers on the construction context, queue for the next drain.

Without a captured SynchronizationContext, the graph drains only when graph.Pump() runs. The execution and its returned task wait until then. See Serialised hosts.

Command notifications and lifetime

CanExecute returns the value last notified, so any thread can call it. CanExecuteChanged and PropertyChanged handlers run on the SynchronizationContext captured when they subscribed.

The bindings dispose their commands. Use command.Dispose() to stop one earlier and cancel its executions.

Synchronous and standalone commands

bindings.Command(parameter => …) takes a synchronous Action<object> and batches its writes. Outside ReactiveBindings, Reactive.Command creates a command on Graph.Current with an effect of its own.

Track status without throwing

AnyPending uses graph.TrackStatus(node): a tracked read of Status that returns pending or failed states without throwing.

Options and threads

GraphOptions is built with With methods:

var graph = new Graph(GraphOptions.Default
    .WithFlightPolicy(FlightPolicy.Queue)
    .WithDispatcher(new ManualDispatcher()));

graph.Dispatch(() => …) marshals a write from another thread, as described in Async and pending.

Tracing

Tracing exposes the trace log through C# methods. Give nodes names, then query why they ran or what they are waiting on.

Name nodes and inspect their causes
var graph = new Graph();

graph.Run(() =>
{
    var count = Tracing.Named("count", () => Signal(1));
    var log = Effect(() => Console.WriteLine(count.Value));
    Tracing.Named("changes", () => EffectOn(() => count.Value, value => { }));
    Flush();

    count.Value = 2;
    Flush();
#if RANVIER_TRACE
    Console.WriteLine(Tracing.Why(graph, log));
    Console.WriteLine(Tracing.Why(graph, "/changes"));
#endif
});
Tracing methods and their F# equivalents
C#F#
Named(label, () => …)Trace.named
Label(graph, node, text)Trace.label
Origin, Why, WhyDepth, WhyNot, History, WaitingOnthe same queries, passed to Trace.render
Snapshot(graph), Snapshot(graph, seq)Trace.snapshot, Trace.snapshotAt, rendered
Resolve, Reconcile, Events, DumpText, DumpTrace.resolve, reconcile, events, dumpText, dump

Each per-node query accepts a node or its identity path, such as "/changes". Use a path to reach a node without a handle, such as an EffectOn.

Conditional labels

Tracing.Label is [Conditional("RANVIER_TRACE")]. Its call is retained only when the calling project defines RANVIER_TRACE.

Limits

Construct a memo with an explicit graph

Use new Memo<int>(graph, _ => …), or new Memo<int>(graph, seed, previous => …) for a memo that receives its previous value. The seed comes before the compute function; owning is the last argument in both constructors.

Edit this page