Extensions and utilities for R3 reactive library including RxCommand, RxObject, data binding source generators, and ReactiveUI-compatible helpers.
$ dotnet add package R3ExtSource‑Generated High-Performance Reactive MVVM & Dynamic Data for .NET
Compile-time bindings, AOT-friendly change sets, and zero-reflection reactive state
Features • Installation • Quick Start • Documentation • Examples • Contributing
R3Ext is a reactive programming library built on R3, combining the best of ReactiveUI, DynamicData, and System.Reactive into a modern, high-performance package. With source-generated bindings, reactive collections, and comprehensive operators, R3Ext makes building reactive applications fast, type-safe, AOT-ready, and maintainable.
R3Ext unifies reactive MVVM, dynamic collections, and operator libraries with a focus on speed, AOT-compatibility, and developer ergonomics:
WhenChanged, WhenObserved, and binding APIs—no runtime expression parsing or reflection.Unified ICommand + IObservable<T> implementation with ReactiveUI-compatible API:
// Simple synchronous command
var saveCommand = RxCommand.Create(() => SaveData());
// Async with cancellation support
var loadCommand = RxCommand.CreateFromTask(async ct => await LoadDataAsync(ct));
// Parameterized commands
var deleteCommand = RxCommand<int, bool>.CreateFromTask(
async (id, ct) => await DeleteItemAsync(id, ct),
canExecute: isLoggedIn.AsObservable()
);
// Monitor execution state
deleteCommand.IsExecuting.Subscribe(busy => UpdateUI(busy));
deleteCommand.ThrownExceptions.Subscribe(ex => ShowError(ex));
// Combine multiple commands
var saveAll = RxCommand<Unit, Unit[]>.CreateCombined(save1, save2, save3);
Source-generated, compile-time safe property bindings with intelligent change tracking:
// WhenChanged - Monitor any property chain with automatic rewiring
viewModel.WhenChanged(vm => vm.User.Profile.DisplayName)
.Subscribe(name => label.Text = name);
// WhenObserved - Observe nested observables with automatic switching
viewModel.WhenObserved(vm => vm.CurrentStream.DataObservable)
.Subscribe(value => UpdateChart(value));
// Two-way binding with type-safe converters
host.BindTwoWay(
h => h.SelectedItem.Price,
target => target.Text,
hostToTarget: price => $"${price:F2}",
targetToHost: text => decimal.Parse(text.TrimStart('$'))
);
// One-way binding with inline transformation
source.BindOneWay(
s => s.Quantity,
target => target.Text,
qty => qty > 0 ? qty.ToString() : "Out of Stock"
);
Key Features:
INotifyPropertyChanged when available, falls back to EveryValueChangedHigh-performance observable collections with rich transformation operators, ported from DynamicData:
// Create observable cache with key-based access
var cache = new SourceCache<Person, int>(p => p.Id);
// Observe changes with automatic caching
cache.Connect()
.Filter(p => p.IsActive)
.Sort(SortExpressionComparer<Person>.Ascending(p => p.Name))
.Transform(p => new PersonViewModel(p))
.Bind(out var items) // Bind to ObservableCollection
.Subscribe();
// Observable list for ordered collections
var list = new SourceList<string>();
list.Connect()
.AutoRefresh(s => s.Length) // Re-evaluate on property changes
.Filter(s => s.StartsWith("A"))
.Subscribe(changeSet => HandleChanges(changeSet));
// Advanced operators
cache.Connect()
.TransformMany(p => p.Orders) // Flatten child collections
.Group(o => o.Status) // Group by property
.DistinctValues(o => o.CustomerId) // Track unique values
.Subscribe();
Operators:
| Category | Operators |
|---|---|
| Filtering | Filter, FilterOnObservable, AutoRefresh |
| Transformation | Transform, TransformMany, TransformAsync |
| Sorting | Sort, SortAsync |
| Grouping | Group, GroupWithImmutableState, GroupOn |
| Aggregation | Count, Sum, Avg, Min, Max |
| Change Tracking | DistinctValues, MergeChangeSet, Clone |
| Binding | Bind, ObserveOn, SubscribeMany |
Performance Features:
Comprehensive operator library organized by category:
// Async coordination
await observable.FirstAsync(cancellationToken);
await observable.LastAsync(cancellationToken);
observable.Using(resource, selector);
// Advanced creation
Observable.FromArray(items, scheduler);
Observable.While(condition, source);
// Collection operations
source.Shuffle(random);
source.PartitionBySize(3);
source.BufferWithThreshold(threshold, maxSize);
// Time-based operations with TimeProvider
source.Throttle(TimeSpan.FromMilliseconds(300), timeProvider);
source.Timeout(TimeSpan.FromSeconds(5), timeProvider);
Observable.Interval(TimeSpan.FromSeconds(1), timeProvider);
// Safe observation patterns
source.ObserveSafe(
onNext: x => Process(x),
onError: ex => LogError(ex),
onCompleted: () => Cleanup()
);
// Swallow and continue
source.SwallowCancellations();
// Multi-stream coordination
Observable.CombineLatestValuesAreAllTrue(stream1, stream2, stream3);
source1.WithLatestFrom(source2, source3, (a, b, c) => new { a, b, c });
ReactiveUI-style Interaction pattern for view/viewmodel communication:
public class ViewModel
{
public Interaction<string, bool> ConfirmDelete { get; } = new();
async Task DeleteAsync()
{
bool confirmed = await ConfirmDelete.Handle("Delete this item?");
if (confirmed) await DeleteItemAsync();
}
}
// In the view
viewModel.ConfirmDelete.RegisterHandler(async interaction =>
{
bool result = await DisplayAlert("Confirm", interaction.Input, "Yes", "No");
interaction.SetOutput(result);
});
End‑to‑end sample with a command triggering an interaction and a view handler:
// ViewModel: trigger an Interaction from a command
public class FilesViewModel : RxObject
{
public Interaction<string, bool> ConfirmDelete { get; } = new();
public RxCommand<string, Unit> DeleteFileCommand { get; }
public FilesViewModel()
{
DeleteFileCommand = RxCommand.CreateFromTask<string>(async (fileName, ct) =>
{
var ok = await ConfirmDelete.Handle($"Delete '{fileName}'?");
if (!ok) return;
await DeleteFileAsync(fileName, ct);
});
}
private Task DeleteFileAsync(string fileName, CancellationToken ct)
=> Task.Delay(100, ct); // replace with real delete
}
// View: register the handler (e.g., MAUI ContentPage)
public partial class FilesPage : ContentPage
{
readonly FilesViewModel _vm;
readonly CompositeDisposable _subscriptions = new();
public FilesPage()
{
InitializeComponent();
_vm = new FilesViewModel();
BindingContext = _vm;
// Show a modal confirmation and return the result to the interaction
_vm.ConfirmDelete.RegisterHandler(async interaction =>
{
bool result = await DisplayAlert("Confirm", interaction.Input, "Delete", "Cancel");
interaction.SetOutput(result);
}).AddTo(_subscriptions);
// Wire a button to the command
DeleteButton.Clicked += (_, __) =>
{
var fileName = SelectedFileName();
_vm.DeleteFileCommand.Execute(fileName);
};
}
protected override void OnDisappearing()
{
base.OnDisappearing();
_subscriptions.Dispose(); // unregister handler
}
string SelectedFileName() => "report.pdf"; // sample
}
Reactive signal helpers for boolean state management:
// Convert any observable to a signal-style boolean
var hasItems = itemsObservable.AsSignal(seed: false, predicate: items => items.Count > 0);
// Boolean stream utilities
var allTrue = Observable.CombineLatestValuesAreAllTrue(isValid, isConnected, isReady);
# Core library with extensions and commands
dotnet add package R3Ext
# Source generator for MVVM bindings (required for bindings)
dotnet add package R3Ext.Bindings.SourceGenerator
# Reactive collections (DynamicData port)
dotnet add package R3.DynamicData
# .NET MAUI integration (optional, for MAUI apps)
dotnet add package R3Ext.Bindings.MauiTargets
using R3;
using R3Ext;
// Create a reactive property
var name = new ReactiveProperty<string>("John");
// Observe changes
name.Subscribe(x => Console.WriteLine($"Name changed to: {x}"));
// Use extension operators
name
.Throttle(TimeSpan.FromMilliseconds(300))
.DistinctUntilChanged()
.Subscribe(x => SaveToDatabase(x));
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.UseR3(); // Enables R3 with MAUI-aware scheduling
return builder.Build();
}
}
public class MainViewModel : RxObject
{
public ReactiveProperty<string> SearchText { get; } = new("");
public ReactiveProperty<bool> IsLoading { get; } = new(false);
public ReadOnlyReactiveProperty<bool> CanSearch { get; }
public RxCommand<Unit, Unit> SearchCommand { get; }
public MainViewModel()
{
// Derive CanSearch from SearchText
CanSearch = SearchText
.Select(text => !string.IsNullOrWhiteSpace(text))
.ToReadOnlyReactiveProperty();
// Create command with CanExecute binding
SearchCommand = RxCommand.CreateFromTask(
async ct => await PerformSearchAsync(ct),
canExecute: CanSearch.AsObservable()
);
// Track execution state
SearchCommand.IsExecuting.Subscribe(loading => IsLoading.Value = loading);
// Handle errors
SearchCommand.ThrownExceptions.Subscribe(ex => ShowError(ex));
}
private async Task PerformSearchAsync(CancellationToken ct)
{
var results = await SearchApiAsync(SearchText.Value, ct);
// Update UI...
}
}
| Category | Description | Key Operators |
|---|---|---|
| Async | Async coordination | FirstAsync, LastAsync, Using |
| Creation | Observable factories | FromArray, While |
| Filtering | Stream filtering | Shuffle, PartitionBySize |
| Collection | Buffering & batching | BufferWithThreshold, PartitionByPredicate |
| Timing | Time-based operations | Throttle, Timeout, Interval |
| Error Handling | Safe observation | ObserveSafe, SwallowCancellations |
| Combining | Multi-stream coordination | WithLatestFrom, CombineLatestValuesAreAllTrue |
| Commands | Reactive commands | RxCommand, InvokeCommand |
| Signals | Boolean state utilities | AsSignal, AsBool |
The binding generator automatically discovers WhenChanged, BindOneWay, and BindTwoWay calls:
// Automatically generates compile-time safe binding code
public void SetupBindings()
{
// Generator detects this pattern and creates efficient binding
this.WhenChanged(vm => vm.User.Profile.Email)
.Subscribe(email => emailLabel.Text = email);
// Two-way bindings also auto-generated
this.BindTwoWay(
vm => vm.Settings.Volume,
view => view.VolumeSlider.Value
);
}
Generator Features:
R3Ext/
├── R3Ext/ # Core library
│ ├── Extensions/ # Extension operators
│ │ ├── AsyncExtensions.cs # Async coordination
│ │ ├── CreationExtensions.cs # Observable factories
│ │ ├── FilteringExtensions.cs # Filtering operators
│ │ ├── TimingExtensions.cs # Time-based operators
│ │ ├── ErrorHandlingExtensions.cs # Error handling
│ │ └── CombineExtensions.cs # Combining operators
│ ├── Commands/ # Reactive commands
│ │ └── RxCommand.cs # ICommand + IObservable
│ ├── Bindings/ # MVVM bindings
│ │ ├── GeneratedBindingStubs.cs # Binding API surface
│ │ └── BindingRegistry.cs # Runtime support
│ ├── Interactions/ # Interaction pattern
│ │ └── Interaction.cs # View-ViewModel communication
│ └── RxObject.cs # MVVM base class
│
├── R3.DynamicData/ # Reactive collections (NEW!)
│ ├── List/ # Observable list operators
│ ├── Cache/ # Observable cache operators
│ ├── Operators/ # Transformation operators
│ └── Binding/ # Collection binding
│
├── R3Ext.Bindings.SourceGenerator/ # Compile-time binding generator
│ ├── BindingGenerator.cs # WhenChanged/WhenObserved generation
│ └── UiBindingMetadata.cs # MAUI UI element metadata
│
├── R3Ext.Bindings.MauiTargets/ # MAUI integration
│ └── GenerateUiBindingTargetsTask.cs # MSBuild task for UI bindings
│
├── R3Ext.Tests/ # Core library tests
├── R3.DynamicData.Tests/ # DynamicData tests (NEW!)
└── R3Ext.SampleApp/ # .NET MAUI sample app
dotnet workload install mauigit clone https://github.com/michaelstonis/R3Ext.git
cd R3Ext
dotnet restore
dotnet build
dotnet test
# Android
dotnet build R3Ext.SampleApp -t:Run -f net9.0-android
# iOS Simulator
dotnet build R3Ext.SampleApp -t:Run -f net9.0-ios
# Mac Catalyst
dotnet build R3Ext.SampleApp -t:Run -f net9.0-maccatalyst
public class ShoppingCartViewModel : RxObject
{
private readonly SourceCache<Product, int> _productsCache;
private readonly ReadOnlyObservableCollection<ProductViewModel> _items;
public ReadOnlyObservableCollection<ProductViewModel> Items => _items;
public ReactiveProperty<string> SearchText { get; } = new("");
public ReadOnlyReactiveProperty<decimal> TotalPrice { get; }
public RxCommand<Unit, Unit> CheckoutCommand { get; }
public ShoppingCartViewModel(IProductService productService)
{
_productsCache = new SourceCache<Product, int>(p => p.Id);
// Observable collection with filtering and transformation
_productsCache.Connect()
.Filter(this.WhenChanged(x => x.SearchText.Value)
.Select(search => new Func<Product, bool>(p =>
string.IsNullOrEmpty(search) ||
p.Name.Contains(search, StringComparison.OrdinalIgnoreCase))))
.Transform(p => new ProductViewModel(p))
.Bind(out _items)
.Subscribe();
// Derived total price
TotalPrice = _productsCache.Connect()
.AutoRefresh(p => p.Quantity)
.Select(_ => _productsCache.Items.Sum(p => p.Price * p.Quantity))
.ToReadOnlyReactiveProperty();
// Command with async execution
CheckoutCommand = RxCommand.CreateFromTask(
async ct => await productService.CheckoutAsync(_productsCache.Items, ct),
canExecute: TotalPrice.Select(total => total > 0)
);
CheckoutCommand.ThrownExceptions
.Subscribe(ex => ShowError($"Checkout failed: {ex.Message}"));
}
}
public class StreamMonitorViewModel : RxObject
{
public ReactiveProperty<DataStream> CurrentStream { get; } = new();
public ReactiveProperty<string> StatusText { get; } = new("");
public StreamMonitorViewModel()
{
// Automatically switches to new stream's observable when CurrentStream changes
this.WhenObserved(vm => vm.CurrentStream.Value.DataObservable)
.Subscribe(data => StatusText.Value = $"Received: {data}");
// Works with nested observable properties
this.WhenObserved(vm => vm.CurrentDocument.Value.AutoSaveProgress)
.Subscribe(progress => UpdateProgressBar(progress));
}
public void SwitchToStream(DataStream newStream)
{
// WhenObserved automatically unsubscribes from old stream
// and subscribes to new stream's DataObservable
CurrentStream.Value = newStream;
}
}
public class DocumentViewModel : RxObject
{
public Interaction<string, bool> ConfirmSave { get; } = new();
public RxCommand<Unit, Unit> SaveCommand { get; }
public DocumentViewModel()
{
SaveCommand = RxCommand.CreateFromTask(async ct =>
{
if (HasUnsavedChanges)
{
bool confirmed = await ConfirmSave.Handle("Save changes?");
if (!confirmed) return;
}
await SaveDocumentAsync(ct);
});
}
}
// In the view
public class DocumentView
{
public DocumentView(DocumentViewModel viewModel)
{
viewModel.ConfirmSave.RegisterHandler(async interaction =>
{
bool result = await DisplayAlert("Confirm", interaction.Input, "Yes", "No");
interaction.SetOutput(result);
});
}
}
Contributions are welcome! Please feel free to submit a Pull Request. For major changes, please open an issue first to discuss what you would like to change.
R3Ext is built on the shoulders of giants, bringing together proven patterns from the reactive programming ecosystem:
R3 by Yoshifumi Kawai (neuecc) and Cysharp
ReactiveUI by the ReactiveUI team
RxCommand pattern for reactive commandsInteraction workflow for view-viewmodel communicationWhenChanged operator inspirationDynamicData by Roland Pheasant and ReactiveMarbles
ReactiveMarbles - Community-driven reactive extensions
R3Ext combines these common reactive patterns with modern .NET features (source generators, AOT compilation, unsafe accessor) to deliver a reactive programming experience.
This project is licensed under the MIT License - see the LICENSE file for details.
Special thanks to:
Made with ❤️ for the Reactive Programming Community