From 399f689f83eecff60dc265e8b75b6eb21c97f67b Mon Sep 17 00:00:00 2001 From: Chris Pulman Date: Sun, 4 Jan 2026 02:49:20 +0000 Subject: [PATCH] Expand ReactiveUI guidelines with best practices The guidelines document now includes comprehensive best practices for modern ReactiveUI development, covering application initialization, source generators, event handling, lifecycle management, architecture, dependency injection, performance, testing, error handling, platform-specific advice, anti-patterns, and migration paths. This update aims to help developers build maintainable, testable, and performant reactive applications. --- reactiveui/docs/guidelines/index.md | 614 +++++++++++++++++++++++++++- 1 file changed, 613 insertions(+), 1 deletion(-) diff --git a/reactiveui/docs/guidelines/index.md b/reactiveui/docs/guidelines/index.md index 0b14a1f0..33f083bd 100644 --- a/reactiveui/docs/guidelines/index.md +++ b/reactiveui/docs/guidelines/index.md @@ -1,2 +1,614 @@ -# Guidelines +# Guidelines + +## Overview + +These guidelines provide best practices and recommendations for building modern ReactiveUI applications. Following these patterns will help you create maintainable, testable, and performant reactive applications. + +## Modern Development Patterns + +### Use RxAppBuilder for Application Initialization + +**RxAppBuilder** is the modern way to initialize ReactiveUI applications. It provides a fluent API for configuring dependency injection, schedulers, and platform-specific features. + +```csharp +var app = RxAppBuilder.CreateReactiveUIBuilder() + .WithWpf() // or .WithMaui(), .WithBlazor(), etc. + .WithViewsFromAssembly(Assembly.GetExecutingAssembly()) + .WithRegistration(locator => + { + // Register your services + locator.RegisterLazySingleton(() => new DataService()); + locator.RegisterLazySingleton(() => new NavigationService()); + }) + .BuildApp(); +``` + +**Benefits:** +- Centralized configuration +- Type-safe service registration +- Platform-specific optimizations +- Simplified testing setup +- Clear dependency graph + +### Use ReactiveUI.SourceGenerators + +**ReactiveUI.SourceGenerators** eliminates boilerplate code through compile-time code generation. Always prefer source generators over manual property implementation or Fody. + +#### Reactive Properties + +```csharp +public partial class MyViewModel : ReactiveObject +{ + // Old way (manual) + private string _name; + public string Name + { + get => _name; + set => this.RaiseAndSetIfChanged(ref _name, value); + } + + // New way (source generators) ✅ + [Reactive] + private string _name = string.Empty; +} +``` + +#### Observable As Property Helper + +```csharp +public partial class MyViewModel : ReactiveObject +{ + // Old way (manual) + private readonly ObservableAsPropertyHelper _fullName; + public string FullName => _fullName.Value; + + public MyViewModel() + { + _fullName = this.WhenAnyValue(x => x.FirstName, x => x.LastName, + (f, l) => $"{f} {l}") + .ToProperty(this, x => x.FullName); + } + + // New way (source generators) ✅ + [Reactive] + private string _firstName = string.Empty; + + [Reactive] + private string _lastName = string.Empty; + + [ObservableAsProperty] + private string _fullName = string.Empty; + + public MyViewModel() + { + _fullNameHelper = this.WhenAnyValue(x => x.FirstName, x => x.LastName, + (f, l) => $"{f} {l}") + .ToProperty(this, nameof(FullName)); + } +} +``` + +#### Reactive Commands + +```csharp +public partial class MyViewModel : ReactiveObject +{ + // Old way (manual) + public ReactiveCommand SaveCommand { get; } + + public MyViewModel() + { + SaveCommand = ReactiveCommand.CreateFromTask(SaveAsync); + } + + private async Task SaveAsync() { /* ... */ } + + // New way (source generators) ✅ + [ReactiveCommand] + private async Task Save() + { + // Your save logic + } +} +``` + +**Benefits:** +- Less boilerplate code +- Compile-time validation +- Better IDE support +- Easier to read and maintain +- No IL weaving complexity + +### Use ObservableEvents for Event Handling + +Instead of manually subscribing to events, use **ReactiveMarbles.ObservableEvents.SourceGenerator** to convert events to observables. + +```csharp +// Old way ❌ +button.Click += Button_Click; +protected override void OnClosed(EventArgs e) +{ + button.Click -= Button_Click; +} + +// New way ✅ +this.WhenActivated(disposables => +{ + button.Events().Click + .Throttle(TimeSpan.FromMilliseconds(500)) + .Subscribe(_ => HandleClick()) + .DisposeWith(disposables); +}); +``` + +### Always Use WhenActivated + +**WhenActivated** ensures proper lifecycle management and prevents memory leaks. + +```csharp +public partial class MainWindow : ReactiveWindow +{ + public MainWindow() + { + InitializeComponent(); + + this.WhenActivated(disposables => + { + // All subscriptions here are automatically disposed + this.Bind(ViewModel, vm => vm.Name, v => v.NameTextBox.Text) + .DisposeWith(disposables); + + this.BindCommand(ViewModel, vm => vm.SaveCommand, v => v.SaveButton) + .DisposeWith(disposables); + }); + } +} +``` + +**Rules:** +- ✅ Always wrap subscriptions in WhenActivated +- ✅ Always call DisposeWith(disposables) +- ❌ Never subscribe without disposing +- ❌ Don't create subscriptions in constructors (outside WhenActivated) + +## Architecture Guidelines + +### ViewModel Design + +#### Keep ViewModels Platform-Agnostic + +ViewModels should not reference platform-specific types or UI frameworks. + +```csharp +// Bad ❌ +public class MyViewModel : ReactiveObject +{ + private readonly Window _window; // Platform-specific + public void ShowDialog() => _window.Show(); +} + +// Good ✅ +public class MyViewModel : ReactiveObject +{ + private readonly IDialogService _dialogService; // Interface + public async Task ShowDialog() => await _dialogService.ShowAsync(); +} +``` + +#### Single Responsibility + +Each ViewModel should have one clear responsibility. + +```csharp +// Bad - too many responsibilities ❌ +public class MainViewModel +{ + public void LoadData() { } + public void SaveData() { } + public void ExportToExcel() { } + public void SendEmail() { } + public void GenerateReport() { } +} + +// Good - focused responsibility ✅ +public class MainViewModel +{ + private readonly IDataService _dataService; + private readonly INavigationService _navigation; + + public ReactiveCommand LoadDataCommand { get; } + public ReactiveCommand NavigateToSettingsCommand { get; } +} +``` + +#### Use Reactive Properties Appropriately + +```csharp +public partial class SearchViewModel : ReactiveObject +{ + [Reactive] + private string _searchText = string.Empty; + + [ObservableAsProperty] + private bool _isSearching; + + [ObservableAsProperty] + private List _results; + + public SearchViewModel() + { + // Derived state from commands + SearchCommand = ReactiveCommand.CreateFromTask( + async () => await PerformSearchAsync(SearchText), + this.WhenAnyValue(x => x.SearchText, text => !string.IsNullOrWhiteSpace(text))); + + SearchCommand.IsExecuting + .ToProperty(this, x => x.IsSearching); + + SearchCommand + .ToProperty(this, x => x.Results); + } + + public ReactiveCommand> SearchCommand { get; } +} +``` + +### Dependency Injection + +#### Register Services with RxAppBuilder + +```csharp +var app = RxAppBuilder.CreateReactiveUIBuilder() + .WithWpf() + .WithViewsFromAssembly(Assembly.GetExecutingAssembly()) + .WithRegistration(locator => + { + // Singletons for stateful services + locator.RegisterLazySingleton(() => new AuthService()); + locator.RegisterLazySingleton(() => new SettingsService()); + + // Transient for ViewModels + locator.Register(() => new MainViewModel()); + locator.Register(() => new DetailsViewModel()); + }) + .BuildApp(); +``` + +#### Constructor Injection Pattern + +```csharp +public class MainViewModel : ReactiveObject +{ + private readonly IDataService _dataService; + private readonly INavigationService _navigation; + + // Constructor injection ✅ + public MainViewModel( + IDataService dataService, + INavigationService navigation) + { + _dataService = dataService; + _navigation = navigation; + } + + // Alternative: Service locator (use sparingly) + public MainViewModel() + { + _dataService = AppLocator.Current.GetService(); + _navigation = AppLocator.Current.GetService(); + } +} +``` + +## Performance Guidelines + +### Use Throttle and Debounce + +Prevent excessive operations on high-frequency events. + +```csharp +// Search as user types +this.WhenAnyValue(x => x.SearchText) + .Throttle(TimeSpan.FromMilliseconds(500)) + .DistinctUntilChanged() + .Where(text => !string.IsNullOrWhiteSpace(text)) + .SelectMany(async text => await SearchAsync(text)) + .ObserveOn(RxSchedulers.MainThreadScheduler) + .Subscribe(results => Results = results); +``` + +### Use DynamicData for Collections + +For reactive collections, always use DynamicData instead of ObservableCollection. + +```csharp +public partial class ItemListViewModel : ReactiveObject +{ + private readonly SourceCache _itemsCache; + private readonly ReadOnlyObservableCollection _items; + + public ReadOnlyObservableCollection Items => _items; + + public ItemListViewModel() + { + _itemsCache = new SourceCache(x => x.Id); + + _itemsCache.Connect() + .Transform(item => new ItemViewModel(item)) + .Filter(vm => vm.IsVisible) + .Sort(SortExpressionComparer.Ascending(x => x.Name)) + .ObserveOn(RxSchedulers.MainThreadScheduler) + .Bind(out _items) + .Subscribe(); + } +} +``` + +### Optimize Observable Chains + +```csharp +// Bad - multiple subscriptions ❌ +this.WhenAnyValue(x => x.Property1).Subscribe(/* ... */); +this.WhenAnyValue(x => x.Property1).Subscribe(/* ... */); +this.WhenAnyValue(x => x.Property1).Subscribe(/* ... */); + +// Good - shared observable ✅ +var sharedObservable = this.WhenAnyValue(x => x.Property1) + .Publish() + .RefCount(); + +sharedObservable.Subscribe(/* ... */); +sharedObservable.Subscribe(/* ... */); +sharedObservable.Subscribe(/* ... */); +``` + +## Testing Guidelines + +### Use ReactiveUI.Testing + +```csharp +[Fact] +public void ViewModel_LoadsData_WhenCommandExecuted() +{ + // Arrange + new TestScheduler().With(scheduler => + { + var mockService = Substitute.For(); + mockService.GetDataAsync().Returns(Observable.Return(testData)); + + var vm = new MainViewModel(mockService); + + // Act + vm.LoadDataCommand.Execute().Subscribe(); + scheduler.AdvanceBy(TimeSpan.FromSeconds(1).Ticks); + + // Assert + vm.Data.Should().NotBeNull(); + vm.IsLoading.Should().BeFalse(); + }); +} +``` + +### Test ViewModels in Isolation + +```csharp +[Fact] +public async Task SearchCommand_FiltersResults_BasedOnSearchText() +{ + // Arrange + var vm = new SearchViewModel(); + vm.SearchText = "test"; + + // Act + await vm.SearchCommand.Execute(); + + // Assert + vm.Results.Should().NotBeEmpty(); + vm.Results.Should().OnlyContain(r => r.Name.Contains("test")); +} +``` + +## Error Handling + +### Use ThrownExceptions + +```csharp +public MyViewModel() +{ + SaveCommand = ReactiveCommand.CreateFromTask(SaveAsync); + + // Handle errors gracefully + SaveCommand.ThrownExceptions + .Subscribe(ex => + { + // Log the error + this.Log().Error(ex, "Failed to save"); + + // Show user-friendly message + ErrorMessage = "Unable to save. Please try again."; + }); +} +``` + +### Validate User Input + +```csharp +public partial class LoginViewModel : ReactiveValidationObject +{ + [Reactive] + private string _username = string.Empty; + + [Reactive] + private string _password = string.Empty; + + public LoginViewModel() + { + // Validation rules + this.ValidationRule( + vm => vm.Username, + username => !string.IsNullOrWhiteSpace(username), + "Username is required"); + + this.ValidationRule( + vm => vm.Password, + password => password?.Length >= 6, + "Password must be at least 6 characters"); + + // Command only executes when valid + LoginCommand = ReactiveCommand.CreateFromTask( + LoginAsync, + this.IsValid()); + } + + [ReactiveCommand] + private async Task Login() { /* ... */ } +} +``` + +## Platform-Specific Guidelines + +### WPF + +```csharp +// Use ReactiveWindow +public partial class MainWindow : ReactiveWindow +{ + public MainWindow() + { + InitializeComponent(); + ViewModel = new MainViewModel(); + + this.WhenActivated(disposables => + { + // Bindings here + }); + } +} +``` + +### MAUI + +```csharp +// Use ReactiveContentPage +public partial class MainPage : ReactiveContentPage +{ + public MainPage() + { + InitializeComponent(); + ViewModel = new MainViewModel(); + + this.WhenActivated(disposables => + { + // Bindings here + }); + } +} +``` + +### Blazor + +```csharp +// Use ReactiveComponentBase and call StateHasChanged +public partial class CounterComponent : ReactiveComponentBase +{ + protected override void OnInitialized() + { + ViewModel = new CounterViewModel(); + + this.WhenActivated(disposables => + { + this.WhenAnyValue(x => x.ViewModel.Count) + .Subscribe(_ => InvokeAsync(StateHasChanged)) + .DisposeWith(disposables); + }); + + base.OnInitialized(); + } +} +``` + +## Common Anti-Patterns to Avoid + +### ❌ Don't Use Static RxApp + +```csharp +// Bad ❌ +RxApp.MainThreadScheduler = _myCustomScheduler; +RxApp.DefaultExceptionHandler = /* ... */; + +// Good ✅ +// Use RxAppBuilder to configure schedulers +var app = RxAppBuilder.CreateReactiveUIBuilder() + .WithCustomScheduler(/* ... */) + .WithDefaultExceptionHandler(/* ... */) + .BuildApp(); +``` + +### ❌ Don't Forget to Dispose + +```csharp +// Bad ❌ +this.WhenAnyValue(x => x.Property).Subscribe(/* ... */); + +// Good ✅ +this.WhenActivated(disposables => +{ + this.WhenAnyValue(x => x.Property) + .Subscribe(/* ... */) + .DisposeWith(disposables); +}); +``` + +### ❌ Don't Mix MVVM Patterns + +```csharp +// Bad - code-behind in view ❌ +private void Button_Click(object sender, EventArgs e) +{ + // Business logic here +} + +// Good - command in ViewModel ✅ +[ReactiveCommand] +private void ExecuteAction() +{ + // Business logic here +} +``` + +### ❌ Don't Block on Async + +```csharp +// Bad ❌ +var result = asyncOperation.Result; +asyncOperation.Wait(); + +// Good ✅ +var result = await asyncOperation; +``` + +## Migration Path + +If you're upgrading from older ReactiveUI versions: + +1. **Replace Fody with SourceGenerators** - See [Migration Guide](~/docs/upgrading/fody-to-sourcegenerators.md) +2. **Adopt RxAppBuilder** - See [Migration Guide](~/docs/upgrading/rxappbuilder-migration.md) +3. **Update to Modern Patterns** - Follow this guide's recommendations +4. **Migrate from Xamarin** - See [Xamarin to MAUI Guide](~/docs/upgrading/xamarin-to-maui.md) + +## Additional Resources + +- [Getting Started](~/docs/getting-started/index.md) +- [Handbook](~/docs/handbook/index.md) +- [Reactive Programming Basics](~/docs/reactive-programming/index.md) +- [Testing Guide](~/docs/handbook/testing.md) +- [Sample Applications](~/docs/resources/samples.md) + +## Platform-Specific Guidelines + +For detailed platform-specific guidance, see: + +- [Framework Guidelines](~/docs/guidelines/framework/toc.yml) +- [Platform Guidelines](~/docs/guidelines/platform/toc.yml) +- [Debugging Guidelines](~/docs/guidelines/debugging/toc.yml)