A field app needs properties, commands, a loading state, and a typed route to the next screen. Those pieces usually arrive as three frameworks that overlap.
MVVMExpress is the shell that keeps them on one registration path. Version 1.3.0 is on nuget.org. Core targets net10.0 and does not reference Microsoft.Maui.Controls. The MAUI host calls UseMvvmExpress. Shared libraries and tests call AddMvvmExpress.
This post is the walkthrough: what 1.3.0 registers, a first ViewModel, navigation, and the pieces Phases 8–10 added on the 1.0 SemVer lock. Public 1.x APIs stay source-compatible. Breaking changes wait for 2.0.0.
What MVVMExpress is
MVVMExpress is a modular MVVM framework for .NET MAUI on Android, iOS, Mac Catalyst, and Windows (single-window). MIT licensed. Manual INotifyPropertyChanged and hand-written commands are first-class. Generators are an accelerator.
The page owns BindingContext. The ViewModel never holds Page. ViewModels depend on INavigator, never on Shell.Current.
Install the packages the screen needs. The host package pulls the MAUI registration. Core stays UI-free so a net10.0 test project can construct the same ViewModel.
.Core
ObservableModel, ViewModel, commands, AsyncState, Outcome, IMessageHub, IModule
.Navigation
UseNavigationPage, UseShell, replace-root, modal stack
.Dialogs
IDialogs, toast on Window.AddOverlay
.Validation
DataAnnotations, MustMatch
.Pagination
SnapshotCollection, PagedCollection, SearchQuery
.Reactive
CombineLatest without System.Reactive
.SourceGenerators
[Notify], [NotifyDependsOn], [RegisterView], [Route], analyzers MVVME001–013
.Testing
FakeNavigator, FakeDialogs, LeakProbe
.Templates
dotnet new mvvmexpress and mvvmexpress-page
Sibling plugins (NetworkMonitor, ApiCache, SecureSession, FormValidation, FeatureFlags, DeepLinks) are wired by the app through Core abstractions. MVVMExpress does not take a PackageReference on them.
Register
DI is Microsoft.Extensions.DependencyInjection. There is no container package to swap in.
builder
.UseMauiApp<App>()
.UseMvvmExpress(o => o
.UseNavigationPage()
.UseDialogs()
.UseAuth<LoginViewModel>());
Enter fullscreen mode Exit fullscreen mode
UseNavigationPage is the login → replace-root → push host. UseShell is an equal host for flyout, tabs, and // routes. Register one of them per window. UseAuth<TChallenge>() wraps GuardedNavigator. Leave that wrapper in place. UseSecureSessionAuth() and UseDeepLinks() fail closed when the sibling package is missing.
Call InitializeComponent() on App before resolving pages. ViewModels talk to IMainThread. Leave MainThread statics out of the ViewModel.
Generated [RegisterView] and [Route] apply from UseMvvmExpress through a module initializer. An explicit Map callback stays optional. A feature assembly registers itself with AddModule<T>():
public sealed class CatalogModule : IModule
{
public void Configure(IServiceCollection services)
{
services.AddSingleton<ICatalog, Catalog>();
}
}
builder.Services.AddModule<CatalogModule>();
Enter fullscreen mode Exit fullscreen mode
A convention scan of *Page / *ViewModel is outside the 1.3 path. MVVME010 reports that in DEBUG.
A ViewModel
ObservableModel.SetProperty compares with EqualityComparer<T>.Default and skips the event when the value is unchanged. NotifyDependsOn raises a named set of dependents. Prefer that over PropertyChanged(null), which refreshes every binding.
public partial class NameViewModel : ViewModel
{
[Notify] private string _first = "";
[Notify] private string _last = "";
[NotifyDependsOn(nameof(First), nameof(Last))]
public string FullName => $"{First} {Last}".Trim();
}
Enter fullscreen mode Exit fullscreen mode
Types that use [Notify] or [AsyncModelCommand] stay partial. [NotifyDependsOn] does not need the Reactive package. Hand-written SetProperty remains valid.
Lifecycle on ViewModel:
Construct (DI)
→ Accept(args) / Accept(query)
→ InitializeAsync(token) once
→ OnNavigatedToAsync(token)
→ OnAppearingAsync(token)
→ OnDisappearingAsync(token)
→ OnNavigatedFromAsync(token)
→ Dispose cancels ViewModelCancellationToken
Enter fullscreen mode Exit fullscreen mode
The token is created in the constructor and cancelled on Dispose. After dispose it stays readable, with IsCancellationRequested true, so a late continuation can still observe cancel. AutoAttachLifecycle (default true) attaches appear and disappear. Dispose is the guaranteed cancel.
AsyncState<T> is the bindable status object: Status, Data, Error, plus IsLoading, IsRefreshing, IsEmpty, HasError, and IsSuccess. LoadAsync returns Outcome<T>. The name is Outcome so it does not collide with an app-level Result<T>.
public AsyncState<IReadOnlyList<Product>> Products { get; } = new();
await Products.LoadAsync(token => catalog.ListAsync(token), ct);
Enter fullscreen mode Exit fullscreen mode
Bind ItemsSource to Products.Data and IsRefreshing to Products.IsRefreshing. The host package ships AsyncStateView for loading, empty, error, and success templates.
Commands
The page binds Button.Command. The ViewModel owns the work, CanExecute, and cancellation. Tests call ExecuteAsync without a visual tree.
AsyncModelCommand runs through IOperationExecutor: CanExecute, a concurrency gate, optional timeout, optional retry, optional debounce or throttle, then the delegate. ICommand.Execute does not throw. Failures go to IErrorSink or IDialogs. ExecuteAsync still rethrows.
SaveCommand = new AsyncModelCommand(
SaveAsync,
() => CanSave,
new AsyncCommandOptions
{
Concurrency = ConcurrencyMode.Prevent,
Timeout = TimeSpan.FromSeconds(15),
RetryCount = 2,
RetryDelay = TimeSpan.FromSeconds(1),
});
Enter fullscreen mode Exit fullscreen mode
ConcurrencyMode is Prevent, CancelPrevious, Queue, Allow, or Replace. Call NotifyCanExecuteChanged when the condition changes. CanExecuteChanged is a weak event, so a button on a popped page does not pin the command. Allocate the command once.
Search text debounce stays on SearchQuery (default 300 ms, minimum length 2). Command debounce is a separate option on AsyncCommandOptions.
Navigation
INavigator is host-agnostic. Every public method returns Outcome and accepts CancellationToken. Navigators hop to IMainThread before new Page() or Shell.GoToAsync. An off-thread factory throws.
UseNavigationPage
Login, then ResetAsync / ReplaceRootAsync so Back cannot return to login. Replaces window.Page with a NavigationPage.
UseShell
Flyout, tabs, or an existing Shell. ResetAsync works when the destination is a root ShellContent.
One host per window
A second call is for a second window.
Typed records are the default argument. The destination implements IAcceptNavArgs<T>. Accept runs before InitializeAsync. A dictionary query exists for deep links through IAcceptNavQuery.
public sealed record ProductDetailsArgs(int ProductId);
await navigator.NavigateToAsync<ProductDetailsViewModel, ProductDetailsArgs>(new(42));
Enter fullscreen mode Exit fullscreen mode
PushModalAsync / PopModalAsync live on IPageNavigator. Toast is an overlay, drawn with Window.AddOverlay. It does not wrap Page.Content, so a later ResetAsync cannot restore a stale tree.
[RequiresAuth] on a ViewModel sends an anonymous session to the challenge type from UseAuth<TChallenge>(). Production tokens stay on Plugin.Maui.SecureSession. The template’s demo sign-in is [email protected] / secret.
What 1.3.0 adds
Phases 8–10 sit on the 1.0 lock. Existing UseAuth, UseNavigationPage, and Map<TViewModel, TPage> calls keep compiling.
[RegisterView] / [Route] apply without a required Map. [NotifyDependsOn]. CommunityToolkit ObservableObject interop. Analyzers MVVME001–003.
9
IModule / AddModule<T>(). Modal stack. UseDeepLinks / UseSecureSessionAuth (fail closed). SectionHostView for in-place tabs. MvvmSearch.
10
Analyzers MVVME010–013. ILLink roots for Core forms, Navigation, Dialogs, and Pagination. 1.0 contract tests.
Templates and the Marketplace wrappers pin the same 1.3.0 pack. The extensions install Plugin.Maui.MVVMExpress.Templates and run dotnet new. They do not copy a private scaffold.
Scaffold
dotnet new install Plugin.Maui.MVVMExpress.Templates
dotnet new mvvmexpress -n MyApp
cd MyApp
dotnet new mvvmexpress-page -n Catalog --namespace MyApp
dotnet test MyApp.Tests
Enter fullscreen mode Exit fullscreen mode
The app template is a NavigationPage host: a counter page, a login that replace-roots back to home, one snapshot list, one FormViewModel screen, and a net10.0 test project. After mvvmexpress-page, call services.AddCatalog() from MauiProgram.
The same commands are in the IDE: MVVMExpress on the VS Code Marketplace and MVVMExpress for Visual Studio 2022+. After the pack is installed, File → New → Project lists MVVMExpress MAUI App.
When something looks wrong
What you see What to doMVVME010
Stop scanning *Page / *ViewModel. Use [RegisterView] / [Route], or an explicit Map.
Page factory throws off the UI thread
Let the navigator construct the page. It hops to IMainThread first.
Back returns to login
Call ResetAsync or ReplaceRootAsync after sign-in on UseNavigationPage.
UseShell and UseNavigationPage both registered
Keep one host per window.
Auth guard reconstructed by hand
UseAuth<TChallenge>() already wraps GuardedNavigator. Register IAuthState.
UseDeepLinks or UseSecureSessionAuth throws at startup
The sibling package is missing. These adapters fail closed.
A popped page stays in memory
The message handler captured this. Subscribe with the recipient argument: static (vm, _) => vm.Refresh().
AddGeneratedViewModels is redundant
1.3 applies generated maps from UseMvvmExpress. Map stays an escape hatch.
Windows opens a second window
The MAUI host is single-window. Sibling adapters stay Android and iOS.
Where MVVMExpress sits next to the other tools
Need Tool MAUI application shell: state, commands, typed navigation, dialogs MVVMExpress Source-generated properties and commands beside an existing toolkit CommunityToolkit.Mvvm — interop package ships with 1.3.0 Observable pipelines ReactiveUI, orPlugin.Maui.MVVMExpress.Reactive when you want CombineLatest without System.Reactive
Typed REST on the same host
Plugin.Maui.HttpForge
Lumina NV* controls
NuvyntraLabs.UIKit
A new MAUI app locked to this shell
Nuvyn — nuvyn init
CommunityToolkit.Mvvm covers properties and commands. Prism.Maui covers page navigation. ReactiveUI covers observable pipelines. MVVMExpress is the pack when the app needs those three plus bindable async state and a Shell or NavigationPage host.
Try it
dotnet new install Plugin.Maui.MVVMExpress.Templates
dotnet new mvvmexpress -n HarborDesk
cd HarborDesk
dotnet test HarborDesk.Tests
Enter fullscreen mode Exit fullscreen mode
Run the app, sign in with the template demo account, and confirm Back does not return to login. To walk command, dialog, form, auth, and list in one sample, clone Playground.
- NuGet: Plugin.Maui.MVVMExpress
- Templates: Plugin.Maui.MVVMExpress.Templates
- Repository: github.com/nuvyntralabs/Plugin.Maui.MVVMExpress
- Get started: nuvyntralabs.github.io/packages/plugin-maui-mvvmexpress/integration/
- Comparison: nuvyntralabs.github.io/packages/plugin-maui-mvvmexpress/comparison/
By Niladri