Scaffold an app
Visual Studio Code: WinUI 3 MVVMExpress on the Marketplace
Visual Studio 2022+: WinUI 3 MVVMExpress on the Marketplace
dotnet new install Plugin.WinUI.MVVMExpress.Templates
dotnet new winui-mvvmexpress -n MyApp
cd MyApp
dotnet test MyApp.TestsThe template is a Frame host that starts on LoginPage, then ResetAsync to HomeViewModel after sign-in, plus details, a form, and a net10.0 test project. Demo sign-in: demo@mvvmexpress.dev / secret. Register your own IAuthState for production tokens.
dotnet new winui-mvvmexpress-page -n Catalog --namespace MyAppThen map the route and call services.AddCatalog() in App startup. Move the ViewModel and service into MyApp.Core if you keep that split.
Template pack on nuget.org: Plugin.WinUI.MVVMExpress.Templates
Template pack on GitHub Packages: Plugin.WinUI.MVVMExpress.Templates
Install into an existing app
Clone the 15-minute path: samples/Playground
git clone https://github.com/nuvyntralabs/Plugin.WinUI.MVVMExpress.gitdotnet add package Plugin.WinUI.MVVMExpress.Core
dotnet add package Plugin.WinUI.MVVMExpress
dotnet add package Plugin.WinUI.MVVMExpress.Navigation
dotnet add package Plugin.WinUI.MVVMExpress.DialogsPlugin.WinUI.MVVMExpress.* restores from nuget.org. CI also publishes GitHub Packages. Add Validation, Pagination, and Testing only when the app uses those surfaces.
dotnet add package Plugin.WinUI.MVVMExpress.Validation
dotnet add package Plugin.WinUI.MVVMExpress.Pagination
dotnet add package Plugin.WinUI.MVVMExpress.TestingRegister the host
Shared libraries and tests call AddMvvmExpress. A WinUI 3 app calls UseWinUIMvvmExpress (or AddWinUIMvvmExpress on IServiceCollection), which registers Core services, replaces IMainThread with DispatcherQueueMainThread, maps IWindowContext to WinUIWindowContext, and auto-attaches Loaded / Unloaded lifecycle.
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.UI.Xaml;
using Plugin.WinUI.MVVMExpress.Hosting;
using Plugin.WinUI.MVVMExpress.Navigation;
using Plugin.WinUI.MVVMExpress.Dialogs;
protected override void OnLaunched(LaunchActivatedEventArgs args)
{
var builder = Host.CreateApplicationBuilder();
builder.Services.AddSingleton<IAuthState, DemoAuthState>();
builder.Services.AddTransient<LoginViewModel>();
builder.Services.AddTransient<HomeViewModel>();
builder.Services.AddTransient<LoginPage>();
builder.Services.AddTransient<HomePage>();
builder.Services.AddSingleton<MainWindow>();
builder.Services.UseWinUIMvvmExpress(o => o
.UseFrameNavigation((nav, _) => nav
.Map<LoginViewModel, LoginPage>("login")
.Map<HomeViewModel, HomePage>("home"))
.UseDialogs()
.UseAuth<LoginViewModel>());
_host = builder.Build();
var window = _host.Services.GetRequiredService<MainWindow>();
window.Activate();
_ = _host.Services.GetRequiredService<INavigator>()
.ResetAsync<LoginViewModel>();
}// net10.0 tests / shared ViewModel projects
services.AddMvvmExpress();
services.AddAuth<LoginViewModel>();UseFrameNavigation registers WinUIFrameNavigator as INavigator / IPageNavigator. UseDialogs replaces NullDialogs with WinUIDialogs and WinUINotifier. UseAuth<TChallenge>() wraps GuardedNavigator — call it after UseFrameNavigation. Do not reconstruct the guard. Put a Frame named NavigationHost in the window. Do not replace Window.Content with a toast host.
| Choose | When |
|---|---|
| UseFrameNavigation + ResetAsync / ReplaceRootAsync | Login → home, or any app that must drop the Frame journal so Back cannot return to login. |
| Second Window + IWindowContext | A tool or inspector window. Register the window, resolve a navigator for that context — do not share one Frame across windows. |
| Do not invent Shell | There is no UseShell. In-place tabs are SectionHostViewModel + SectionHostView. |
First screen
dotnet new winui-mvvmexpress already ships this first screen. After UseWinUIMvvmExpress above, a first page is a ViewModel, SetProperty, AsyncModelCommand, and a XAML bind. The command token is the ViewModel token — Dispose cancels ViewModelCancellationToken. Playground remains the cloneable 15-minute path in the product repo.
public sealed class HomeViewModel : PageViewModel
{
private int _count;
public HomeViewModel(INavigator navigator)
: base(navigator)
{
IncrementCommand = new AsyncModelCommand(IncrementAsync);
}
public int Count
{
get => _count;
private set => SetProperty(ref _count, value);
}
public AsyncModelCommand IncrementCommand { get; }
private async Task IncrementAsync(CancellationToken cancellationToken)
{
await Task.Delay(80, cancellationToken);
Count++;
}
}<Page x:Class="MyApp.Pages.HomePage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<StackPanel Margin="24">
<TextBlock Text="{Binding Count}" />
<Button Content="Increment" Command="{Binding IncrementCommand}" />
</StackPanel>
</Page>First ViewModel
Inherit ViewModel. Bind AsyncState to the page. Put work on AsyncModelCommand so IsRunning, Cancel, and the ViewModel token stay aligned. Do not show a dialog or call Frame.Navigate from the ViewModel.
using Plugin.WinUI.MVVMExpress.ComponentModel;
using Plugin.WinUI.MVVMExpress.Input;
using Plugin.WinUI.MVVMExpress.State;
public sealed class HomeViewModel : ViewModel
{
public AsyncState<IReadOnlyList<Product>> Products { get; } = new();
public AsyncModelCommand RefreshCommand { get; }
public HomeViewModel(ICatalog catalog)
{
RefreshCommand = new AsyncModelCommand(
ct => Products.LoadAsync(token => catalog.ListAsync(token), ct));
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
RefreshCommand.Cancel();
}
base.Dispose(disposing);
}
}Dispose cancels ViewModelCancellationToken. The token remains readable after dispose. Page XAML binds ItemsSource to Products.Data and IsRefreshing to Products.IsRefreshing (or RefreshCommand.IsRunning).
Commands
- ModelCommand / ModelCommand<T> — sync; weak CanExecuteChanged.
- AsyncModelCommand / AsyncModelCommand<T> — async, IsRunning, Cancel, ExecuteAsync; weak CanExecuteChanged.
- ICommand.Execute never throws. Failures go to IErrorSink / IDialogs. ExecuteAsync still rethrows.
- AsyncCommandOptions: timeout, retry, Debounce, Throttle, ConcurrencyMode (Prevent, CancelPrevious, Queue, Allow, Replace).
SaveCommand = new AsyncModelCommand(
SaveAsync,
() => CanSave,
new AsyncCommandOptions
{
Concurrency = ConcurrencyMode.Prevent,
Timeout = TimeSpan.FromSeconds(15),
RetryCount = 2,
RetryDelay = TimeSpan.FromSeconds(1),
});Collections (mid and large lists)
Do not Add in a loop for mid or large lists. AddRange raises one CollectionChanged Reset. Large lists must also virtualize — ItemsRepeater / ListView virtualization. The framework will not pretend 100,000 realized rows are fine.
var items = new ObservableRangeCollection<Product>();
items.AddRange(page); // one CollectionChanged Reset
items.ReplaceRange(next);Messaging
The handler must use the recipient argument so a weak subscribe does not pin the ViewModel. Do not write (msg) => this.Refresh() — that captures this and defeats the weak table.
hub.Subscribe<HomeViewModel, CartChanged>(
this,
static (vm, _) => vm.Refresh(),
weak: true);Dialogs
Add Plugin.WinUI.MVVMExpress.Dialogs and call UseDialogs() on UseWinUIMvvmExpress. Inject IDialogs for alerts/confirm and INotifier for toast. WinUIDialogs hops to IMainThread like WinUINotifier. WinUIToastPresenter draws an overlay — it never wraps or replaces Window.Content, so ResetAsync cannot restore a stale tree. Do not replace Window.Content with a toast host. Tests use FakeDialogs.
public sealed class ProductEditViewModel : PageViewModel
{
public ProductEditViewModel(IDialogs dialogs, INotifier notifier)
: base(navigator: null, dialogs)
{
}
public async Task DeleteAsync(CancellationToken cancellationToken)
{
var ok = await Dialogs!.ConfirmAsync(
"Delete product",
"This cannot be undone.",
accept: "Delete",
cancel: "Cancel",
cancellationToken);
if (!ok)
{
return;
}
await _catalog.DeleteAsync(_productId, cancellationToken);
await Notifier.ToastAsync("Deleted", cancellationToken: cancellationToken);
}
}Validation
Plugin.WinUI.MVVMExpress.Validation ships DataAnnotations plus IValidator and MustMatchAttribute. The package includes ILLink.Descriptors.xml that roots Required, MinLength, MaxLength, StringLength, Range, RegularExpression, EmailAddress, Compare, and MustMatch. Custom attributes need an app-level descriptor. FluentValidation is an adapter the app may add. WinUI 3 bindings use WinUIFormViewModel (INotifyDataErrorInfo).
public sealed class ProductDraft
{
[Required, StringLength(80)]
public string Name { get; set; } = "";
[Range(0.01, 1_000_000)]
public decimal Price { get; set; }
}
var summary = await validator.ValidateAsync(draft, cancellationToken);
if (!summary.IsValid)
{
return Outcome.Failure("validation", summary.Messages[0].Message);
}Forms, dirty guard, undo
FormViewModel lives in Core and does not reference WinUI 3. WinUIFormViewModel adds INotifyDataErrorInfo. Field(name, value) creates a FormField<T>. Bind(field, propertyName, notifyCanExecute) wires the public property and CanExecute. When IDialogs is registered, leaving a dirty form confirms “Discard changes?”. Tests set DirtyNavigation = DirtyNavigationMode.SilentBlock. SubmitAsync(work) calls MarkClean() on success.
public sealed class ProductEditViewModel : WinUIFormViewModel
{
private readonly FormField<string> _name;
public ProductEditViewModel()
{
_name = Field("Name", "");
Bind(_name, nameof(Name), () => SaveCommand.NotifyCanExecuteChanged());
}
public string Name
{
get => _name.Value ?? "";
set => _name.Value = value;
}
private Task<Outcome> SaveAsync(CancellationToken ct) =>
SubmitAsync(token => _catalog.SaveAsync(Name, token), cancellationToken: ct);
}Pagination and search
PagedCollection<T> / DelegatePagedCollection<T> own load-more, refresh, and retry — not a live inbox. SnapshotCollection<T> loads once in InitializeAsync; later LoadAsync calls are no-ops unless force is true. SearchQuery.Text binds to a TextBox or MvvmSearch; filter from CommittedText after debounce.
public sealed class InboxViewModel : ViewModel
{
public SnapshotCollection<Conversation> Inbox { get; }
public SearchQuery Search { get; } = new();
public InboxViewModel(IInbox catalog)
{
Inbox = new SnapshotCollection<Conversation>(
ct => catalog.ListAsync(Search.CommittedText, ct));
}
protected override Task InitializeAsync(CancellationToken cancellationToken) =>
Inbox.LoadAsync(cancellationToken: cancellationToken);
}
await Products.RefreshAsync(ct);
await Products.LoadMoreAsync(ct);Chat-style host
A desktop messenger is one persistent window, in-place tabs, a filterable inbox, and a thread on the Frame stack. Bind SectionHostView to the host ViewModel — do not write visibility flippers in code-behind. Auth and forms stay on FormViewModel / IAuthState / UseAuth.
builder.Services.UseWinUIMvvmExpress(o => o
.UseFrameNavigation((nav, _) => nav
.Map<LoginViewModel, LoginPage>("login")
.Map<ChatHostViewModel, ChatHostPage>("chats")
.Map<ChatThreadViewModel, ChatThreadPage>("thread"))
.UseDialogs()
.UseAuth<LoginViewModel>());
await Navigator.ResetAsync<ChatHostViewModel>();
await Navigator.NavigateToAsync<ChatThreadViewModel, ChatNavArgs>(new(id));public sealed class ChatHostViewModel : SectionHostViewModel
{
public ChatHostViewModel()
{
Inbox = Add("chats", new ChatInboxViewModel(seed));
Add("updates", new ChatInboxViewModel([]));
}
public ChatInboxViewModel Inbox { get; }
}
// Bind SectionHostView. DataContext is the host; Current is the visible child.Auth adapters
In-memory IAuthState exists for samples and tests. Production apps register their own IAuthState. AddSecureSessionAuth() in Core fails closed unless you supply a factory — there is no UseSecureSessionAuth() host helper and no Plugin.Maui.SecureSession PackageReference. GuardedNavigator remains the implementation; UseAuth is the getting-started API.
builder.Services.AddSingleton<IAuthState, DemoAuthState>();
builder.Services.UseWinUIMvvmExpress(o => o
.UseFrameNavigation()
.UseDialogs()
.UseAuth<LoginViewModel>());
await Navigator.ResetAsync<HomeViewModel>(); // after sign-in
await Navigator.ResetAsync<LoginViewModel>(); // after sign-outTesting
Plugin.WinUI.MVVMExpress.Testing is net10.0. ViewModels in the template live in a shared net10.0 project so they can be tested without WinUI 3. FakeNavigator is InMemoryNavigator — assert Current, Stack, and CanGoBack. LeakProbe.Track returns a WeakReference; IsCollected takes that reference. Drop the strong reference before the assert. AppearAsync / DisappearAsync drive lifecycle without a page. ScopedNavigator covers page-scope push/pop GC.
[Fact]
public async Task OpenDetails_pushes_details()
{
var navigator = new FakeNavigator()
.Map<ProductDetailsViewModel>("details");
var vm = new ProductListViewModel(navigator);
await vm.AppearAsync();
await vm.OpenDetailsAsync(42);
Assert.Equal(typeof(ProductDetailsViewModel), navigator.Current);
Assert.True(navigator.CanGoBack);
}
[Fact]
public void Dispose_makes_the_viewmodel_collectable()
{
Assert.True(LeakProbe.IsCollected(CreateAndDispose()));
}
static WeakReference CreateAndDispose()
{
var vm = new HomeViewModel();
var leak = LeakProbe.Track(vm);
vm.Dispose();
return leak;
}Sample map
There is no separate WinUI 3 SampleApp repository. New apps start with dotnet new winui-mvvmexpress. The 15-minute path is Playground (command, navigation, dialog, form, auth, list, second window).
Clone and run: samples/Playground
| Sample | ViewModels | What it integrates |
|---|---|---|
| dotnet new winui-mvvmexpress | Login / Home / Details / Edit | Scaffolded host: Frame, replace-root, form, tests |
| Playground | Login / Home / Details / Edit | 15-minute path: UseAuth, command, nav, dialog, form, list, second window |
Pitfalls
- Do not show a dialog or call Frame.Navigate from a ViewModel. Use IDialogs and INavigator.
- Do not Add in a loop for mid or large lists. Use AddRange / ReplaceRange.
- Do not capture this in a MessageHub handler. Use the recipient argument.
- Do not bind a non-virtualized panel to thousands of rows.
- Do not store tokens on the ViewModel. Register an IAuthState adapter.
- Do not invent a Shell host. Use Frame + SectionHostView.
- Do not reconstruct GuardedNavigator after UseFrameNavigation. Call UseAuth<TChallenge>().
- Do not ConfigureAwait(false) then construct a view or call Frame.Navigate. Navigators hop to IMainThread first.
- Do not replace Window.Content with a toast host.
- Do not name the Frame something other than NavigationHost unless you pass a custom frame accessor.
- Do not add a PackageReference to Plugin.Maui.MVVMExpress.* or Plugin.Wpf.MVVMExpress.*. This is an independent family.
- Do not mix dispatcher statics in ViewModels. IMainThread is the only marshal API.
- Hand-written SetProperty and Map<TViewModel, TView> are the 1.0 path. Generators are out of 1.0.