Skip to content
Nuvyntra Labs

Getting started

Get started with WPF MVVMExpress

From dotnet new wpf-mvvmexpress, a Marketplace IDE extension, or NuGet install to a testable ViewModel: 1.0.0 templates, first screen, UseFrameNavigation, Playground clone, FakeNavigator / LeakProbe, and forms.

Scaffold an app

Visual Studio Code: WPF MVVMExpress on the Marketplace

Visual Studio 2022+: WPF MVVMExpress on the Marketplace

dotnet new install Plugin.Wpf.MVVMExpress.Templates
dotnet new wpf-mvvmexpress -n MyApp
cd MyApp
dotnet test MyApp.Tests

The 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 wpf-mvvmexpress-page -n Catalog --namespace MyApp

Then 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.Wpf.MVVMExpress.Templates

Template pack on GitHub Packages: Plugin.Wpf.MVVMExpress.Templates

Install into an existing app

Clone the 15-minute path: samples/Playground

git clone https://github.com/nuvyntralabs/Plugin.Wpf.MVVMExpress.git
dotnet add package Plugin.Wpf.MVVMExpress.Core
dotnet add package Plugin.Wpf.MVVMExpress
dotnet add package Plugin.Wpf.MVVMExpress.Navigation
dotnet add package Plugin.Wpf.MVVMExpress.Dialogs

Plugin.Wpf.* 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.Wpf.MVVMExpress.Validation
dotnet add package Plugin.Wpf.MVVMExpress.Pagination
dotnet add package Plugin.Wpf.MVVMExpress.Testing

Register the host

Shared libraries and tests call AddMvvmExpress. A WPF app calls UseWpfMvvmExpress (or AddWpfMvvmExpress on IServiceCollection), which registers Core services, replaces IMainThread with DispatcherMainThread, maps IWindowContext to WpfWindowContext, and auto-attaches Loaded / Unloaded lifecycle.

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Plugin.Wpf.MVVMExpress.Hosting;
using Plugin.Wpf.MVVMExpress.Navigation;
using Plugin.Wpf.MVVMExpress.Dialogs;

public partial class App : Application
{
    private IHost? _host;

    private async void OnStartup(object sender, StartupEventArgs e)
    {
        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.UseWpfMvvmExpress(o => o
            .UseFrameNavigation((nav, _) => nav
                .Map<LoginViewModel, LoginPage>("login")
                .Map<HomeViewModel, HomePage>("home"))
            .UseDialogs()
            .UseAuth<LoginViewModel>());

        _host = builder.Build();
        await _host.StartAsync();
        _host.Services.GetRequiredService<MainWindow>().Show();
        await _host.Services.GetRequiredService<INavigator>()
            .ResetAsync<LoginViewModel>();
    }
}
// net10.0 tests / shared ViewModel projects
services.AddMvvmExpress();
services.AddAuth<LoginViewModel>();

UseFrameNavigation registers WpfFrameNavigator as INavigator / IPageNavigator. UseDialogs replaces NullDialogs with WpfDialogs and WpfNotifier. UseAuth<TChallenge>() wraps GuardedNavigator — call it after UseFrameNavigation. Do not reconstruct the guard. Put a Frame named NavigationHost in the window and wrap the tree in AdornerDecorator so toasts can draw without replacing Content.

ChooseWhen
UseFrameNavigation + ResetAsync / ReplaceRootAsyncLogin → home, or any app that must drop the Frame journal so Back cannot return to login.
Second Window + IWindowContextA tool or inspector window. Register the window, resolve a navigator for that context — do not share one Frame across windows.
Do not invent ShellThere is no UseShell. In-place tabs are SectionHostViewModel + SectionHostView.

First screen

dotnet new wpf-mvvmexpress already ships this first screen. After UseWpfMvvmExpress 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 call MessageBox.Show or Frame.Navigate from the ViewModel.

using Plugin.Wpf.MVVMExpress.ComponentModel;
using Plugin.Wpf.MVVMExpress.Input;
using Plugin.Wpf.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 — VirtualizingStackPanel / VirtualizingPanel.IsVirtualizing. 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.Wpf.MVVMExpress.Dialogs and call UseDialogs() on UseWpfMvvmExpress. Inject IDialogs for alerts/confirm and INotifier for toast. WpfDialogs hops to IMainThread like WpfNotifier. WpfToastPresenter draws on AdornerLayer — it never wraps or replaces Window.Content, so ResetAsync cannot restore a stale tree. Wrap MainWindow content in AdornerDecorator. 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.Wpf.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. WPF bindings use WpfFormViewModel (INotifyDataErrorInfo) with ValidatesOnNotifyDataErrors=True.

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 WPF. WpfFormViewModel 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 : WpfFormViewModel
{
    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.UseWpfMvvmExpress(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.UseWpfMvvmExpress(o => o
    .UseFrameNavigation()
    .UseDialogs()
    .UseAuth<LoginViewModel>());

await Navigator.ResetAsync<HomeViewModel>();  // after sign-in
await Navigator.ResetAsync<LoginViewModel>(); // after sign-out

Testing

Plugin.Wpf.MVVMExpress.Testing is net10.0. ViewModels in the template live in a shared net10.0 project so they can be tested without WPF. 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 WPF SampleApp repository. New apps start with dotnet new wpf-mvvmexpress. The 15-minute path is Playground (command, navigation, dialog, form, auth, list, second window).

Clone and run: samples/Playground

SampleViewModelsWhat it integrates
dotnet new wpf-mvvmexpressLogin / Home / Details / EditScaffolded host: Frame, replace-root, form, tests
PlaygroundLogin / Home / Details / Edit15-minute path: UseAuth, command, nav, dialog, form, list, second window

Pitfalls

  • Do not call MessageBox.Show or 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 new Page() or Frame.Navigate. Navigators hop to IMainThread first.
  • Do not omit AdornerDecorator — toasts have nowhere to draw.
  • 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.*. This is an independent family.
  • Do not mix Dispatcher.CurrentDispatcher 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.

Discussion

Comment on Plugin.Wpf.MVVMExpress. The thread lives on this component's GitHub repository (nuvyntralabs/Plugin.Wpf.MVVMExpress). Sign in with GitHub — Giscus uses Discussions, Utterances uses Issues.