Skip to content

Getting Started

Nicolás Seijas edited this page Jul 20, 2026 · 3 revisions

Getting Started

Rambla targets current .NET (the repo builds on .NET 10). The core is netstandard2.0, so it works broadly; UI integration ships as adapters.


1. Reference the packages

  • Rambla — the framework-agnostic core (includes the [State] source generator).
  • Rambla.Wpf — the WPF dispatcher adapter.

(Packages are not yet published to NuGet; for now reference the projects or build from source. See the Roadmap.)

2. Install a scheduler (WPF)

At application startup, on the UI thread, install the dispatcher scheduler so background writes marshal to the UI automatically. Do this once in App:

// App.xaml.cs
using System.Windows;
using Rambla.Wpf;

public partial class App : Application
{
    protected override void OnStartup(StartupEventArgs e)
    {
        base.OnStartup(e);

        // Every RamblaState created without an explicit scheduler now flushes
        // onto this dispatcher, at Background priority (so high-frequency state
        // never starves input or rendering).
        DispatcherStateScheduler.InstallAsDefault();
    }
}

Without an adapter, the default is a synchronous ImmediateStateScheduler — great for tests and non-UI code, but on a background thread it raises PropertyChanged off the UI thread, so always install a UI scheduler in a real app.

3. Declare state with [State]

Inherit RamblaState and annotate backing fields. The generator emits the observable property and strips a leading underscore (_bidBid). The class must be partial.

using Rambla;

public partial class MarketViewModel : RamblaState
{
    [State] private decimal _bid;
    [State] private decimal _ask;
    [State] private decimal _last;
    [State] private decimal _pnl;
}

That generates, for each field:

public decimal Bid
{
    get => _bid;
    set => SetField(ref _bid, value);
}

4. A complete WPF example

ViewModel (uses the ambient scheduler installed in step 2):

using Rambla;

public partial class TickerViewModel : RamblaState
{
    [State] private string _symbol = "BTC-USD";
    [State] private decimal _price;
    [State] private decimal _change;
}

Window XAML — bind exactly as you would to any INotifyPropertyChanged:

<StackPanel>
    <TextBlock Text="{Binding Symbol}" FontWeight="Bold" />
    <TextBlock Text="{Binding Price, StringFormat=N2}" FontSize="28" />
    <TextBlock Text="{Binding Change, StringFormat='{}{0:+0.00;-0.00}%'}" />
</StackPanel>

Code-behind — set the DataContext and start feeding:

public partial class MainWindow : Window
{
    private readonly TickerViewModel _vm = new(); // uses the installed dispatcher scheduler

    public MainWindow()
    {
        InitializeComponent();
        DataContext = _vm;
        _ = FeedAsync(); // fire-and-forget background feed
    }

    private async Task FeedAsync()
    {
        var rng = new Random();
        while (true)
        {
            // These writes happen on a background thread — no Dispatcher.Invoke:
            _vm.Price = 60_000m + rng.Next(-500, 500);
            _vm.Change = (decimal)(rng.NextDouble() * 4 - 2);
            await Task.Delay(1); // ~1000 updates/sec; Rambla coalesces to the UI
        }
    }
}

The feed writes ~1000 times/sec from a worker; Rambla coalesces those into a few UI notifications per second. No Dispatcher.Invoke, no OnPropertyChanged.

5. Drive it from a background feed (WebSocket/timer)

The point of Rambla is that the producer doesn't care about the UI thread:

async Task RunFeedAsync(TickerViewModel vm, IAsyncEnumerable<Quote> stream, CancellationToken ct)
{
    await foreach (Quote q in stream.WithCancellation(ct))
    {
        vm.Price = q.Price;   // any thread; Rambla marshals + coalesces
        vm.Change = q.Change;
    }
}

If a value ticks many times before the next frame, the UI only ever sees the latest — that's coalescing, and it's the whole point (see Philosophy).

6. Batch related writes (a coherent frame)

Group writes so the UI is notified once, and never mid-update — it won't render a new Bid beside a stale Ask:

using (vm.BeginUpdate())
{
    vm.Bid = quote.Bid;
    vm.Ask = quote.Ask;
    vm.Last = quote.Last;
}

// or the lambda form:
vm.Update(() =>
{
    vm.Bid = quote.Bid;
    vm.Ask = quote.Ask;
});

This is notification coherence, not cross-thread state atomicity — see Core Semantics.

7. Publish a whole snapshot

A clean pattern for feed-driven views is a single Apply that batches everything:

public partial class MarketViewModel : RamblaState
{
    [State] private decimal _bid;
    [State] private decimal _ask;
    [State] private decimal _last;
    [State] private decimal _volume;

    public void Apply(Quote q)
    {
        using (BeginUpdate())
        {
            Bid = q.Bid;
            Ask = q.Ask;
            Last = q.Last;
            Volume = q.Volume;
        }
    }
}

// producer:
vm.Apply(quote); // one coherent flush per quote, from any thread

8. Derived values (V1 pattern)

V1 has no [DependsOn] yet, so a derived value is just another [State] field you set in the same batch as its inputs:

public partial class MarketViewModel : RamblaState
{
    [State] private decimal _bid;
    [State] private decimal _ask;
    [State] private decimal _spread;

    public void Apply(decimal bid, decimal ask)
    {
        using (BeginUpdate())
        {
            Bid = bid;
            Ask = ask;
            Spread = ask - bid; // computed once, notified in the same flush
        }
    }
}

Bind {Binding Spread} normally. (Automatic derived notification is on the Roadmap.)

9. Collections

ObservableCollection<T> is not thread-safe and isn't built for high-frequency change bursts. For now:

  • Build/replace the list off-thread, then assign or add on the UI thread.
  • Keep individual row objects as RamblaState so their fields update at high frequency while the collection itself changes rarely (this is what the market dashboard sample does).

A purpose-built RamblaList<T> (batched changes, efficient diffing) is Phase 2 — see the Roadmap.

10. Use it without a UI (and in tests)

Pass a scheduler explicitly. ImmediateStateScheduler flushes synchronously, which makes tests deterministic:

using Rambla.Scheduling;

var vm = new MarketViewModel(ImmediateStateScheduler.Instance);
var changed = new List<string>();
((INotifyPropertyChanged)vm).PropertyChanged += (_, e) => changed.Add(e.PropertyName!);

vm.Bid = 1.23m;
vm.Bid = 1.24m; // coalescing still applies within a flush

// With the immediate scheduler each set flushes inline, so:
Assert.Contains(nameof(vm.Bid), changed);

For coalescing assertions, inject a manual scheduler that queues flushes and drains them on demand (see the repo's ManualStateScheduler in the tests).

11. Metrics (opt-in)

Turn on lifetime counters to see how much work coalescing saved:

// per instance:
var vm = new MarketViewModel(scheduler, collectMetrics: true);
// or globally:
RamblaOptions.Default.CollectMetrics = true;

StateMetrics m = vm.Metrics;
Console.WriteLine($"{m.Mutations} mutations → {m.Notifications} notifications " +
                  $"({m.CoalescingRatio:P1} coalesced)");

Metrics are off by default so the hot path stays allocation- and contention-free.

12. Tuning the refresh rate

The shipped DispatcherStateScheduler posts flushes at Background priority, so it already yields to input and rendering and coalesces naturally under load.

For a hard cap (e.g. "at most 60 UI updates/sec regardless of load"), the market dashboard sample includes a ThrottledDispatcherScheduler you can copy — it drives flushes from a DispatcherTimer at a fixed rate. Use the sample's latency probe to find the knee for your workload (usually ~60 Hz); see Benchmarks.

// RamblaOptions.Default.MaxRefreshRate is reserved for the built-in coalescing
// scheduler (Roadmap Phase 1); today it documents intent.
RamblaOptions.Default.MaxRefreshRate = 60;

Clone this wiki locally