Source‑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:
- 🚀 High Performance: Built on R3’s zero-allocation foundation; optimized hot paths and minimal JIT surfaces.
- 🔧 Source-Generated Bindings: Pure compile-time generation for
WhenChanged,WhenObserved, and binding APIs—no runtime expression parsing or reflection. - ⚡ Native AOT & Trimming: No hidden reflection or dynamic codegen; explicit property chains in generated code ensure safe trimming and AOT-ready binaries.
- 📉 Low Allocation Profile: Binding subscriptions and change set operators minimize transient allocations and closures.
- 🔁 Incremental Change Processing: DynamicData port optimized for diff-based updates (filter, transform, group, sort) avoiding full recomputation.
- 🧪 Build-Time Validation: Invalid property paths fail at build instead of throwing at runtime.
- 📊 Reactive Collections: Full DynamicData port—observable caches/lists with rich operators for shaping live data.
- 📱 MAUI First-Class: Automatic UI thread marshaling and MAUI-aware scheduling for responsive apps.
- 🎯 Proven Patterns: ReactiveUI-compatible APIs (commands, interactions) widely adopted in production.
- 🔍 Strongly Typed: Compile-time verification of property paths and binding expressions; null-propagation support.
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:
- WhenChanged: Tracks property chains with INPC detection and fallback to polling
- WhenObserved: Automatically switches subscriptions when parent observables change
- Intelligent Monitoring: Uses
INotifyPropertyChangedwhen available, falls back toEveryValueChanged - Automatic Rewiring: Handles intermediate property replacements transparently
- UnsafeAccessor: Access internal/private members without reflection (NET8.0+)
- Zero Runtime Cost: All binding code generated at compile-time
High-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:
- Optimized change sets minimize allocations
- Incremental updates reduce processing overhead
- Virtual change sets support for large collections
- Efficient key-based lookups in caches
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- .NET 9.0 or later
- C# 12 or later
- R3 1.3.0+
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:
- Compile-time validation of property paths
- Zero runtime reflection or IL generation
- Automatic rewiring on intermediate property replacements
- Support for nullable chains with null propagation
- Works with internal/private members via UnsafeAccessor
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
- .NET 9 SDK (Download)
- .NET MAUI workload:
dotnet workload install maui
git clone https://github.com/michaelstonis/R3Ext.git
cd R3Ext
dotnet restore
dotnet builddotnet 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-maccatalystpublic 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.
- Follow existing code style and conventions
- Add tests for new features
- Update documentation for API changes
- Ensure all tests pass before submitting PR
R3Ext is built on the shoulders of giants, bringing together proven patterns from the reactive programming ecosystem:
-
R3 by Yoshifumi Kawai (neuecc) and Cysharp
- High-performance reactive foundation with zero-allocation design
- Core observable primitives and scheduling infrastructure
- Native AOT and trimming support
-
ReactiveUI by the ReactiveUI team
RxCommandpattern for reactive commandsInteractionworkflow for view-viewmodel communication- MVVM binding concepts and
WhenChangedoperator inspiration
-
DynamicData by Roland Pheasant and ReactiveMarbles
- Complete port of observable collections to R3
- Cache and list operators for reactive collections
- Change set optimization and transformation pipelines
-
ReactiveMarbles - Community-driven reactive extensions
- Extension operator implementations
- Advanced observable composition patterns
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:
- Yoshifumi Kawai (neuecc) and the Cysharp team for R3's exceptional performance foundation
- The ReactiveUI team for pioneering reactive MVVM patterns in .NET
- Roland Pheasant for DynamicData's innovative observable collection patterns
- The ReactiveMarbles community for comprehensive reactive operator libraries
- The .NET Community for continuous support and feedback
Made with ❤️ for the Reactive Programming Community