Overview
MVVM: Model (data/business), View (.axaml), ViewModel (presentation logic, bindable state). ViewModel knows nothing about View. Views bind to ViewModel properties and commands.
INotifyPropertyChanged (Manual)
public class PersonViewModel : INotifyPropertyChanged
{
private string _name = "";
public string Name
{
get => _name;
set { _name = value; PropertyChanged?.Invoke(this, new(nameof(Name))); }
}
public event PropertyChangedEventHandler? PropertyChanged;
}
CommunityToolkit.Mvvm (Recommended)
Source-generator based, no reflection, clean code.
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
public partial class MainViewModel : ObservableObject
{
[ObservableProperty]
private string _name = "";
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SaveCommand))]
private bool _isDirty;
[RelayCommand(CanExecute = nameof(CanSave))]
private async Task SaveAsync()
{
// save logic
}
private bool CanSave() => IsDirty && !string.IsNullOrEmpty(Name);
}
NuGet: CommunityToolkit.Mvvm
Generated: Name property with change notification, NameChanged partial method hook, SaveCommand as AsyncRelayCommand.
ReactiveUI (Alternative)
using ReactiveUI;
public class MainViewModel : ReactiveObject
{
private string _name = "";
public string Name
{
get => _name;
set => this.RaiseAndSetIfChanged(ref _name, value);
}
public ReactiveCommand<Unit, Unit> SaveCommand { get; }
public MainViewModel()
{
var canSave = this.WhenAnyValue(x => x.Name, n => !string.IsNullOrEmpty(n));
SaveCommand = ReactiveCommand.CreateFromTask(SaveAsync, canSave);
}
private async Task SaveAsync() { /* ... */ }
}
| Avalonia 12.x (12.0+) | Avalonia 11.x (11.x) | |
|---|---|---|
| NuGet | ReactiveUI.Avalonia (12.1.x on NuGet requires Avalonia >= 12.1.3; later 14.x line exists — check NuGet) |
Avalonia.ReactiveUI (ends at 11.3.x) |
| Namespace | using ReactiveUI.Avalonia; |
using Avalonia.ReactiveUI; |
App setup (12.0+):
using ReactiveUI.Avalonia;
AppBuilder.Configure<App>()
.UsePlatformDetect()
.UseReactiveUI(rxui => { /* optional rxui.WithRegistration(...) */ })
.RegisterReactiveUIViewsFromEntryAssembly();
(11.x) .UseReactiveUI() with no arguments. DI variants in 12: UseReactiveUIWithMicrosoftDependencyResolver, ...WithAutofac, ...WithDryIoc, ...WithNinject (separate ReactiveUI.Avalonia.* packages, namespace ReactiveUI.Avalonia.Splat). Base classes: ReactiveWindow<TVM>, ReactiveUserControl<TVM>; routing via <rxui:RoutedViewHost Router="{Binding Router}"/>.
ICommand (Manual)
public class RelayCommand : ICommand
{
private readonly Action _execute;
private readonly Func<bool>? _canExecute;
public RelayCommand(Action execute, Func<bool>? canExecute = null)
{ _execute = execute; _canExecute = canExecute; }
public event EventHandler? CanExecuteChanged;
public bool CanExecute(object? p) => _canExecute?.Invoke() ?? true;
public void Execute(object? p) => _execute();
public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}
ViewLocator Pattern
Auto-maps ViewModels → Views by naming convention (FooViewModel → FooView):
public class ViewLocator : IDataTemplate
{
public Control? Build(object? data)
{
if (data is null) return null;
var name = data.GetType().FullName!.Replace("ViewModel", "View");
var type = Type.GetType(name);
return type != null
? (Control)Activator.CreateInstance(type)!
: new TextBlock { Text = $"Not Found: {name}" };
}
public bool Match(object? data) => data is ViewModelBase;
}
Register in App.axaml:
<Application.DataTemplates>
<local:ViewLocator/>
</Application.DataTemplates>
ViewModel-First Navigation
Pattern: Router/NavigationViewModel holds current page ViewModel, ContentControl displays it:
<ContentControl Content="{Binding CurrentPage}"/>
public partial class MainViewModel : ObservableObject
{
[ObservableProperty]
private ViewModelBase _currentPage = new HomeViewModel();
public void NavigateTo(ViewModelBase page) => CurrentPage = page;
}
Design-Time DataContext
<Window xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
d:DataContext="{d:DesignInstance vm:MainViewModel, IsDesignTimeCreatable=True}">
ObservableCollection for Lists
public ObservableCollection<PersonViewModel> People { get; } = new();
Use ObservableCollection<T> (not List<T>) so UI updates on add/remove.
Community Libraries
| Library | NuGet / Repo | Purpose |
|---|---|---|
| CommunityToolkit.Mvvm | CommunityToolkit.Mvvm |
Source-generator MVVM (ObservableObject, RelayCommand) — recommended |
| ReactiveUI | ReactiveUI.Avalonia (12.0+) / Avalonia.ReactiveUI (11.x) |
Reactive MVVM with Rx.NET observables |
| Prism | Prism.Avalonia |
Full MVVM framework with regions, modules, DI, dialogs |
| Epoxy | Epoxy.Avalonia |
Lightweight multi-platform MVVM |
| FuncUI | Avalonia.FuncUI |
F# MVU pattern with view DSL |
| ReactiveElmish.Avalonia | GitHub: JordanMarr/ReactiveElmish.Avalonia | F# MVU with XAML views |
| Stylet.Avalonia | Stylet.Avalonia |
Lightweight ViewModel-first MVVM (Caliburn.Micro-inspired) |
| HanumanInstitute.MvvmDialogs | HanumanInstitute.MvvmDialogs.Avalonia |
Open dialogs from ViewModel in pure MVVM |
| ReactiveValidation | ReactiveValidation |
Fluent-style property validation with UI messages |
| Markup Declarative | Avalonia.Markup.Declarative |
Declarative C# UI and MVU helpers |
| Lemon.ModuleNavigation | GitHub: NeverMorewd/Lemon.ModuleNavigation | .NET Generic Host + module navigation |
| AsyncNavigation | GitHub: NeverMorewd/AsyncNavigation | Async navigation via Microsoft.Extensions.DI |
Common Mistakes
- Using
List<T>instead ofObservableCollection<T>— list changes not reflected in UI - Not calling
PropertyChangedwith exact property name — usenameof() - Doing UI work in ViewModel (creating controls, accessing
DataContext) - Creating
DispatcherTimerin a ViewModel off the UI thread in 12 — it uses the current thread's dispatcher, not always the UI thread - Async command without
AsyncRelayCommand— exceptions swallowed, UI blocks - Using
Avalonia.ReactiveUIin an Avalonia 12 project — package stops at 11.3.x; switch toReactiveUI.Avaloniaandusing ReactiveUI.Avalonia; - Forgetting
partialkeyword on CommunityToolkit ViewModel class — source gen fails silently