Loading...
Loading...
MVVM for .NET desktop apps (WPF, Avalonia, WinForms, MAUI) with CommunityToolkit.Mvvm 8.4+ source generators: [ObservableProperty] partial properties, [RelayCommand] async commands, Generic Host + DI, UI-thread dispatching, navigation, messaging, and testable view models. Load when the user mentions "MVVM", "ViewModel", "WPF", "desktop app", "ObservableProperty", "RelayCommand", "ICommand", "INotifyPropertyChanged", "Dispatcher", or "UI thread".
npx skill4agent add codewithmukesh/dotnet-claude-kit desktop-mvvmWindowMessageBoxDispatcherVisibilityINotifyPropertyChanged[ObservableProperty]partialLangVersion=previewasync void[RelayCommand]TaskIAsyncRelayCommandIHttpClientFactorynewusing CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
public sealed partial class OrdersViewModel(IOrderService orders, TimeProvider clock) : ObservableObject
{
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SaveCommand))]
public partial string? CustomerName { get; set; }
[ObservableProperty]
public partial OrderRow? SelectedOrder { get; set; }
public ObservableCollection<OrderRow> Orders { get; } = [];
// Generates LoadCommand (IAsyncRelayCommand) + LoadCancelCommand
[RelayCommand(IncludeCancelCommand = true)]
private async Task LoadAsync(CancellationToken ct)
{
Orders.Clear();
await foreach (var order in orders.StreamAsync(ct))
Orders.Add(order); // continuation resumes on the UI thread
}
[RelayCommand(CanExecute = nameof(CanSave))]
private Task SaveAsync(CancellationToken ct) =>
orders.CreateAsync(CustomerName!, clock.GetUtcNow(), ct);
private bool CanSave() => !string.IsNullOrWhiteSpace(CustomerName);
partial void OnSelectedOrderChanged(OrderRow? value) =>
CustomerName = value?.Customer; // generated hook, no setter boilerplate
}partialAsyncLoadAsyncLoadCommandAllowConcurrentExecutions = trueLoadCommand.IsRunningIsBusyStartupUriApp.xamlApp.xaml.cspublic sealed partial class App : Application
{
private readonly IHost host = CreateHost();
private static IHost CreateHost()
{
var builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings
{
ContentRootPath = AppContext.BaseDirectory // appsettings.json next to the exe, not the cwd
});
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddSingleton<IMessenger>(WeakReferenceMessenger.Default);
builder.Services.AddHttpClient<IOrderService, OrderService>();
builder.Services.AddTransient<OrdersViewModel>();
builder.Services.AddSingleton<MainWindow>();
return builder.Build();
}
protected override void OnStartup(StartupEventArgs e)
{
host.Start(); // starts IHostedService background work, if any
host.Services.GetRequiredService<MainWindow>().Show();
base.OnStartup(e);
}
protected override void OnExit(ExitEventArgs e)
{
// Process exit is the one place sync-over-async is acceptable (like Program.cs)
host.StopAsync(TimeSpan.FromSeconds(5)).GetAwaiter().GetResult();
host.Dispose();
base.OnExit(e);
}
}
public sealed partial class MainWindow : Window
{
public MainWindow(OrdersViewModel viewModel)
{
InitializeComponent();
DataContext = viewModel;
}
}InitializeComponentDataContextAppContext.BaseDirectoryavaloniaawaitFileSystemWatcher// 1. Progress<T> captures the UI SynchronizationContext when constructed on the UI thread
[RelayCommand]
private async Task ExportAsync(CancellationToken ct)
{
var progress = new Progress<int>(percent => ExportProgress = percent);
await Task.Run(() => exporter.Export(Orders.ToList(), progress, ct), ct); // CPU work off the UI thread
}
// 2. A tiny abstraction for background-origin events, implemented per framework
public interface IUiDispatcher
{
Task InvokeAsync(Action action);
}
public sealed class WpfUiDispatcher : IUiDispatcher
{
public async Task InvokeAsync(Action action) =>
await Application.Current.Dispatcher.InvokeAsync(action);
}
public sealed class AvaloniaUiDispatcher : IUiDispatcher
{
public async Task InvokeAsync(Action action) =>
await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(action);
}Progress<T>IUiDispatcherDispatcheraction(); return Task.CompletedTask;Task.Runpublic sealed partial class Navigator(IServiceProvider services) : ObservableObject
{
[ObservableProperty]
public partial ObservableObject? Current { get; set; }
public void NavigateTo<TViewModel>() where TViewModel : ObservableObject =>
Current = services.GetRequiredService<TViewModel>();
}<!-- Shell window: the content area follows Navigator.Current -->
<ContentControl Content="{Binding Navigator.Current}" />
<!-- App resources: one DataTemplate per view model maps VM → View -->
<DataTemplate DataType="{x:Type vm:OrdersViewModel}">
<views:OrdersView />
</DataTemplate>Assert.IsType<OrdersViewModel>(navigator.Current)IServiceProviderTransientSingletonpublic sealed record OrderSaved(Guid OrderId);
// Sender
messenger.Send(new OrderSaved(order.Id));
// Receiver
public sealed partial class DashboardViewModel : ObservableObject, IRecipient<OrderSaved>
{
public DashboardViewModel(IMessenger messenger) => messenger.RegisterAll(this);
public void Receive(OrderSaved message) => RefreshCommand.Execute(null);
[RelayCommand]
private Task RefreshAsync(CancellationToken ct) => Task.CompletedTask; // reload tiles
}WeakReferenceMessengerpublic sealed partial class CustomerFormViewModel : ObservableValidator
{
[ObservableProperty]
[NotifyDataErrorInfo]
[Required, MaxLength(100)]
public partial string? Name { get; set; }
[RelayCommand]
private void Submit()
{
ValidateAllProperties();
if (HasErrors) return;
// save
}
}ObservableValidatorINotifyDataErrorInfo[Fact]
public async Task SaveCommand_EmptyCustomer_CannotExecute()
{
// Arrange
var vm = new OrdersViewModel(new InMemoryOrderService(), new FakeTimeProvider());
// Act
vm.CustomerName = " ";
// Assert
Assert.False(vm.SaveCommand.CanExecute(null));
}FakeTimeProviderMauiProgram.CreateMauiApp()builder.ServicesShell.Current.GoToAsync("orders")MainThread.InvokeOnMainThreadAsyncx:DataTypeasync void// BAD — exceptions crash the app, button stays enabled, .Result can deadlock the UI thread
public ICommand LoadCommand => new RelayCommand(async () => await LoadAsync());
private void Refresh() => Orders = service.GetAllAsync().Result;
// GOOD — generated async command with cancellation and re-entrancy protection
[RelayCommand]
private async Task LoadAsync(CancellationToken ct) => await ReloadAsync(ct);// BAD — ObservableCollection raises CollectionChanged off the UI thread → crash
await Task.Run(() => { foreach (var o in Fetch()) Orders.Add(o); });
// GOOD — compute in the background, mutate on the UI thread
var rows = await Task.Run(() => Fetch().ToList(), ct);
foreach (var row in rows) Orders.Add(row);// BAD — untestable, ties the VM to WPF
if (MessageBox.Show("Delete?", "", MessageBoxButton.YesNo) == MessageBoxResult.Yes) { }
// GOOD — an interface the view layer implements
public interface IDialogService
{
Task<bool> ConfirmAsync(string title, string message);
}
if (await dialogs.ConfirmAsync("Delete order", $"Delete {SelectedOrder!.Number}?")) { }// BAD — 8 lines per property, typo-prone string names
private string? name;
public string? Name
{
get => name;
set { if (name != value) { name = value; OnPropertyChanged(nameof(Name)); } }
}
// GOOD
[ObservableProperty]
public partial string? Name { get; set; }// BAD — click handler talks to services; nothing is testable
private async void SaveButton_Click(object sender, RoutedEventArgs e) =>
await new OrderService().SaveAsync(NameBox.Text);
// GOOD — the view binds to a command; the view model owns behavior
// <Button Content="Save" Command="{Binding SaveCommand}" />| Scenario | Recommendation |
|---|---|
| New cross-platform desktop app | Avalonia + CommunityToolkit.Mvvm (see |
| Windows-only, existing WPF skills or controls | WPF + CommunityToolkit.Mvvm + Generic Host |
| Mobile + desktop from one codebase | MAUI (or Avalonia if Linux desktop is required) |
| Legacy WinForms | Keep WinForms; move logic into view models ( |
| Observable property | |
Derived property (e.g. | |
| Button enablement depends on state | |
| Long-running command | |
| Busy indicator | Bind to |
| CPU-heavy work | |
| Cross-VM notifications | |
| Dialogs / file pickers | |
| Need trimming / Native AOT | Avalonia with compiled bindings; WPF/WinForms don't support trimming |