Skip to content
Nuvyntra Labs

Getting started

Get started with MVVMExpress

From dotnet new mvvmexpress, a Marketplace IDE extension, or NuGet install to a testable ViewModel: 1.3.0 templates, first screen, UseNavigationPage vs UseShell, Playground clone, FakeNavigator / LeakProbe, forms, generators, modules, and the in-repo sample map.

Scaffold an app

Visual Studio Code: MVVMExpress on the Marketplace

Visual Studio 2022+: MVVMExpress on the Marketplace

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

The template is a NavigationPage host that starts on MainPage / MainPageViewModel ([Notify] + IncrementCommand), with IGreetingService registered by AddMain(), plus login → replace-root, one list, one form, and a net10.0 test project. Demo sign-in: demo@mvvmexpress.dev / secret. Production tokens: Plugin.Maui.SecureSession.

dotnet new mvvmexpress-page -n Catalog --namespace MyApp

Then map the route and call services.AddCatalog() in MauiProgram. Move the ViewModel and service into MyApp.Core if you keep that split.

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

Template pack on nuget.org: Plugin.Maui.MVVMExpress.Templates

Install into an existing app

Clone the 15-minute path: samples/Playground

All in-repo samples: samples/

git clone https://github.com/nuvyntralabs/Plugin.Maui.MVVMExpress.git
dotnet add package Plugin.Maui.MVVMExpress.Core
dotnet add package Plugin.Maui.MVVMExpress

Plugin.Maui.* restores from GitHub Packages. Add the org feed first: Use nuvyntralabs GitHub Packages from a C# project

Add Navigation, Dialogs, Validation, Pagination, Reactive, SourceGenerators, Compatibility, and Testing only when the app uses those surfaces.

dotnet add package Plugin.Maui.MVVMExpress.Navigation
dotnet add package Plugin.Maui.MVVMExpress.Dialogs
dotnet add package Plugin.Maui.MVVMExpress.Validation
dotnet add package Plugin.Maui.MVVMExpress.Pagination
dotnet add package Plugin.Maui.MVVMExpress.Reactive
dotnet add package Plugin.Maui.MVVMExpress.SourceGenerators
dotnet add package Plugin.Maui.MVVMExpress.Testing

Register the host

Shared libraries and tests call AddMvvmExpress. A MAUI app calls UseMvvmExpress, which registers Core services, replaces IMainThread with MauiMainThread, marshals command/property notifications, and auto-attaches ViewModelLifecycleBehavior.

using Plugin.Maui.MVVMExpress.Hosting;
using Plugin.Maui.MVVMExpress.Navigation;
using Plugin.Maui.MVVMExpress.Dialogs;

public static class MauiProgram
{
    public static MauiApp CreateMauiApp()
    {
        var builder = MauiApp.CreateBuilder();
        builder
            .UseMauiApp<App>()
            .UseMvvmExpress(o => o
                .UseNavigationPage()
                .UseDialogs()
                .UseAuth<LoginViewModel>());

        return builder.Build();
    }
}
// net10.0 tests / shared ViewModel projects
services.AddMvvmExpress();
services.AddAuth<LoginViewModel>();

UseNavigationPage is the login → replace-root → push host. UseShell is an equal host for flyout / tabs / // absolute routes. Do not register both unless you really have two hosts. UseDialogs replaces NullDialogs with MauiDialogs and MauiNotifier. UseAuth<TChallenge>() wraps GuardedNavigator — UseSecureSessionAuth() is the production adapter (fail closed). Generated [Route] / [RegisterView] / [RequiresAuth] apply from UseMvvmExpress via a ModuleInitializer, so getting started does not need Map or AddGeneratedViewModels(). AddModule<T>() registers a feature assembly. Call InitializeComponent() on App before resolving pages.

ChooseWhen
UseNavigationPage + ResetAsync / ReplaceRootAsyncLogin → home, chat host, or any app that must drop the back-stack so Back cannot return to login. Replaces window.Page with a NavigationPage.
UseShellFlyout / tab catalog, existing Shell routes, or //home as a root ShellContent (AuthApp). Equal host to NavigationPage — flyout, tabs, and // absolute routes are tested at the same depth as ResetAsync. ResetAsync only works when the destination is a root ShellContent.
Do not register bothUnless you really have two hosts (two windows). One INavigator per IWindowContext.

First screen

dotnet new mvvmexpress already ships this first screen. After UseMvvmExpress above, a first page is a partial ViewModel, [Notify], [AsyncModelCommand], and a XAML bind. The command token is the ViewModel token — Dispose cancels ViewModelCancellationToken. Do not mash MauiProgram, the ViewModel, and the page into one file. Playground remains the cloneable 15-minute path in the product repo.

public partial class HomeViewModel : PageViewModel
{
    [Notify] private int _count;

    [AsyncModelCommand]
    private async Task IncrementAsync(CancellationToken cancellationToken)
    {
        await Task.Delay(80, cancellationToken);
        Count++;
    }
}
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:vm="clr-namespace:MyApp"
             x:Class="MyApp.HomePage"
             x:DataType="vm:HomeViewModel">
    <VerticalStackLayout Padding="24">
        <Label Text="{Binding Count}" />
        <Button Text="Increment" Command="{Binding IncrementCommand}" />
    </VerticalStackLayout>
</ContentPage>

Add Plugin.Maui.MVVMExpress.SourceGenerators with PrivateAssets=all. Types must be partial. Hand-written SetProperty stays valid — the next section shows that path with AsyncState.

First ViewModel

Inherit ViewModel. Bind AsyncState to the page — or use AsyncStateView / BusyOverlayBehavior in the host package. Put work on AsyncModelCommand so IsRunning, Cancel, and the ViewModel token stay aligned. Bind Button.Command to AsyncModelCommand on 0.6.0+ (UI-thread marshal + weak CanExecuteChanged). Do not call Page.DisplayAlert or Shell.Current from the ViewModel.

using Plugin.Maui.MVVMExpress.ComponentModel;
using Plugin.Maui.MVVMExpress.Input;
using Plugin.Maui.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 in CollectionView — the framework will not pretend 100,000 realized cells 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.Maui.MVVMExpress.Dialogs and call UseDialogs() on UseMvvmExpress. Inject IDialogs for alerts/confirm and INotifier for toast. MauiDialogs hops to IMainThread like MauiNotifier. MauiToastPresenter draws on Window.AddOverlay — it never wraps or replaces Page.Content, so ResetAsync cannot restore a stale tree. Tests use FakeDialogs (it implements both). Inject IToastPresenter to record toasts without a window.

public sealed class ProductEditViewModel : PageViewModel
{
    public ProductEditViewModel(IDialogs dialogs, INotifier notifier)
    {
        /* store dialogs + notifier */
    }

    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.Maui.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. XAML Validation.For remains Plugin.Maui.FormValidation.

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 MAUI. Field(name, value) creates a FormField<T>. Bind(field, propertyName, notifyCanExecute) wires the public property and CanExecute — do not write a manual PropertyChanged wrapper. Bind FormField.Error / HasError. When IDialogs is registered, leaving a dirty form confirms “Discard changes?”. Tests set DirtyNavigation = DirtyNavigationMode.SilentBlock. SubmitAsync(work) calls MarkClean() on success. Use [MustMatch(nameof(Password))] or MustMatch(password, confirm).

public sealed class ProductEditViewModel : FormViewModel
{
    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);
}

Reactive derived state

Plugin.Maui.MVVMExpress.Reactive does not take System.Reactive. Core stays Rx-free. CombineLatest derives a value from two properties; dispose the observable with the ViewModel. Search debounce remains SearchQuery in Pagination.

using Plugin.Maui.MVVMExpress.Reactive;

_fullName = PropertyObservable.CombineLatest(
    PropertyObservable.Observe(this, nameof(First), () => First ?? ""),
    PropertyObservable.Observe(this, nameof(Last), () => Last ?? ""),
    static (first, last) => $"{first} {last}".Trim());

_fullName.Subscribe(_ => Notify(nameof(FullName)));

Source generators

Add Plugin.Maui.MVVMExpress.SourceGenerators with PrivateAssets=all. Attributes live in Core. Types must be partial. UseMvvmExpress applies generated [Route] / [RegisterView] / [RequiresAuth] via a ModuleInitializer, so UseNavigationPage() / UseShell() without a Map callback is enough. You can still call services.AddGeneratedViewModels() or Map explicitly. [NotifyDependsOn] wires computed properties without the Reactive package. Hand-written SetProperty and Map<TViewModel> remain escape hatches.

[RegisterViewModel]
[RegisterView(typeof(GeneratedCatalogPage))]
[Route("generated")]
[RequiresAuth]
public partial class GeneratedCatalogViewModel : ViewModel
{
    [Notify]
    [NotifyAlso(nameof(Label))]
    private string _query = "";

    [Notify]
    [PersistState]
    private string _draft = "";

    [NotifyDependsOn(nameof(Query))]
    public string Label => $"Q: {Query}";

    [ModelCommand]
    private void Clear() => Query = "";
}

builder.UseMvvmExpress(o => o.UseNavigationPage().UseDialogs());
// generated maps apply — Map / AddGeneratedViewModels() stay optional

Pagination and search

PagedCollection<T> / DelegatePagedCollection<T> own load-more, refresh, and retry — not a live chat inbox. SnapshotCollection<T> loads once in InitializeAsync; later LoadAsync calls are no-ops unless force is true. After appear, mutate with AddLocal / Insert. CollectionBind.AsyncFetch is the blessed RemainingItemsThreshold path when the fetch is async. Do not pair DelegatePagedCollection with CollectionView RemainingItemsThreshold when the fetch is sync (MVVME013). SearchQuery.Text binds to Entry or MvvmSearch; filter from CommittedText after debounce. Android SearchBar is not required.

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);
}

// catalog paging (not a live inbox)
await Products.RefreshAsync(ct);
await Products.LoadMoreAsync(ct);

Chat-style host

A WhatsApp-style app is one persistent screen, in-place tabs, a filterable inbox, and a thread on a NavigationPage stack. That is not a Shell + PagedCollection + appear/refresh app. Bind SectionHostView to SelectCommand and CurrentKey — do not write visibility flippers in code-behind. Auth and forms stay on FormViewModel / IAuthState / UseAuth.

Reference app: WhatsApp clone using MVVMExpress Framework

builder.UseMvvmExpress(o =>
{
    o.UseNavigationPage((nav, _) => nav
        .Map<LoginViewModel, LoginPage>("login")
        .Map<ChatHostViewModel, ChatHostPage>("chats")
        .Map<ChatThreadViewModel, ChatThreadPage>("thread"));
    o.UseDialogs();
    o.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 to SelectCommand and CurrentKey.
// Hub handlers: CoalescingDispatcher (marshal + coalesce).

Auth and offline adapters

In-memory IAuthState, IAccountService, ICache, ICachedFetcher, and IConnectivityProbe exist for samples and tests. IAuthState exposes Email, DisplayName, and Changed. Production apps call UseSecureSessionAuth() (Plugin.Maui.SecureSession) or adapt the sibling themselves. Missing adapter throws — it is never a silent no-op. GuardedNavigator remains the implementation; UseAuth is the getting-started API.

// Auth — UseAuth wraps GuardedNavigator. UseSecureSessionAuth is the production adapter.
builder.UseMvvmExpress(o => o
    .UseNavigationPage()
    .UseDialogs()
    .UseAuth<AuthLoginViewModel>()
    .UseSecureSessionAuth()
    .UseDeepLinks());

// Or adapt IAuthState yourself:
// services.AddSingleton<IAuthState>(sp =>
//     new SecureSessionAuthState(sp.GetRequiredService<ISecureSession>()));

await Navigator.ResetAsync<AuthHomeViewModel>();  // after sign-in
await Navigator.ResetAsync<AuthLoginViewModel>(); // after sign-out

// Cache-first catalog — adapt Plugin.Maui.ApiCache
var cached = await cache.GetAsync<IReadOnlyList<Product>>("catalog", ct);
if (cached is not null && !probe.IsOnline)
{
    return Outcome<IReadOnlyList<Product>>.Success(cached);
}

IConnectivityProbe should wrap Plugin.Maui.NetworkMonitor when the app must distinguish validated internet from a captive portal. Do not treat MAUI Connectivity.NetworkAccess as “online enough to sync.”

Testing

Plugin.Maui.MVVMExpress.Testing is net10.0. ViewModels in the sample host live in a shared net10.0 project so they can be tested without MAUI. FakeNavigator is InMemoryNavigator — assert Current, Stack, and CanGoBack. LeakProbe.Track returns a WeakReference; IsCollected takes that reference, not a Func. Drop the strong reference before the assert (the leak snippet uses the first-screen HomeViewModel). AppearAsync / DisappearAsync drive lifecycle without a page. Button + popped-page collection is a Core test scenario (weak CanExecuteChanged), not a LeakProbe Button API. ScopedNavigator covers page-scope push/pop GC.

public sealed class ProductListViewModel : PageViewModel
{
    public ProductListViewModel(INavigator navigator)
        : base(navigator)
    {
    }

    public Task OpenDetailsAsync(int id, CancellationToken cancellationToken) =>
        Navigator!.NavigateToAsync<ProductDetailsViewModel, ProductDetailsArgs>(
            new ProductDetailsArgs(id), cancellationToken);
}

[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;
}
dotnet test tests/Plugin.Maui.MVVMExpress.Core.Tests
dotnet test tests/Plugin.Maui.MVVMExpress.Samples.Tests

Sample map

There is no separate MVVMExpress.SampleApp repository. New apps start with dotnet new mvvmexpress. Samples still ship in the product repo. The 15-minute path is Playground (command, navigation, dialog, form, auth, list). First-run login is AuthApp: sign in → home, plus register and forgot password (demo@mvvmexpress.dev / secret). AuthApp uses UseMvvmExpress(o => o.UseShell().UseDialogs().UseAuth<AuthLoginViewModel>()), ResetAsync replace-root, [RequiresAuth], and FormViewModel dirty confirm. The flyout catalog still lives in Plugin.Maui.MVVMExpress.Sample.

Clone and run: samples/Playground

In-repo sample map: samples/

Standalone reference app: WhatsApp clone using MVVMExpress Framework

SampleViewModelsWhat it integrates
BasicCounterViewModelViewModel, SetProperty, NotifyDependsOn, ModelCommand
CRUDProductList / ProductEditFormViewModel dirty / undo / redo, AsyncState, IValidator
NavigationHome / ProductDetails / ScopedCatalogPageViewModel, INavigator, IAcceptNavArgs<T>, IAcceptNavQuery, INotifier toast, IViewModelScopeFactory
Page stackPageStack / PageStackItemIPageNavigator, URI query, Stack / CanGoBack / PopToRoot / Replace / Reset
Auth (flyout)Login / SecureHomeIAuthState, GuardedNavigator — push secure; adapt SecureSession
dotnet new mvvmexpressMainPage / Login / list / formScaffolded host: bindings, IGreetingService, replace-root, tests
PlaygroundHome / Details / Edit / Login15-minute path: UseAuth, command, nav, dialog, form, list
AuthAppAuthLogin / AuthHome / Register / ForgotUseAuth<AuthLoginViewModel>, ResetAsync replace-root, MustMatch, dirty confirm
OfflineOfflineCatalogViewModelICachedFetcher + FetchPolicy — adapt ApiCache / OfflineSync
PaginationPagedProductViewModelDelegatePagedCollection load-more + refresh (not a live inbox)
Chat hostChatHost / ChatInboxSectionHostViewModel, SnapshotCollection, SearchQuery.CommittedText, CoalescingDispatcher
WhatsApp cloneWhatsAppUICloneStandalone reference app: SectionHost, SnapshotCollection, NavigationPage
ReactiveSearchViewModelSearchQuery debounce + PropertyObservable.CombineLatest
EnterpriseEnterpriseShell / CatalogStatusChild composition, IFeatureSwitch, hub, busy, probe, auth gate
GeneratedGeneratedCatalogViewModel[Notify], [NotifyDependsOn], [ModelCommand], [PersistState], [RegisterView], [Route], [RequiresAuth]
ModulesCatalogModuleIModule / AddModule<T>() registers routes and services
Escape hatchManualCounterViewModelHand-written SetProperty — still valid

Pitfalls

  • Do not call Page.DisplayAlert or Shell.Current 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 StackLayout to thousands of rows. Pagination + CollectionView virtualization are required at mid/large scale.
  • Do not store tokens on the ViewModel. Use Plugin.Maui.SecureSession.
  • Do not treat MAUI Connectivity as validated internet. Adapt Plugin.Maui.NetworkMonitor.
  • Do not enable convention View/ViewModel scanning. 1.3 registration is generators + ModuleInitializer. MVVME010 is a DEBUG error. Handwritten Map remains valid.
  • Do not inject AppShell into App before InitializeComponent(). Resolve the shell in CreateWindow from IServiceProvider.
  • Do not reconstruct GuardedNavigator after UseNavigationPage / UseShell. Call UseAuth<TChallenge>() and UseSecureSessionAuth() (or register IAuthState).
  • Do not bind Button.Command to AsyncModelCommand on 0.5.0-preview. Use 0.6.0+ for UI-thread marshal and weak CanExecuteChanged.
  • Do not ConfigureAwait(false) then new Page() or Shell.GoToAsync. Navigators hop to IMainThread first.
  • Do not pair DelegatePagedCollection with CollectionView RemainingItemsThreshold when the fetch is sync. Use SnapshotCollection.
  • Do not two-way bind SearchQuery to Android SearchBar. Bind Entry or MvvmSearch and filter from CommittedText.
  • Do not RefreshAsync from OnAppearingAsync on a live inbox. Do not ReplaceRange a visible BindableLayout.
  • Do not mix MAUI MainThread statics in ViewModels. IMainThread is the only marshal API.
  • Hand-written SetProperty and Map<TViewModel> remain valid. Generators are an accelerator.

Discussion

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