DEV Community

Cover image for MVVMExpress 1.3.0: One Application Shell for .NET MAUI
NuvyntraLabs
NuvyntraLabs

Posted on

MVVMExpress 1.3.0: One Application Shell for .NET MAUI

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.

Package What it adds
.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 MVVME001013
.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.

Host When
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 demo@mvvmexpress.dev / 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.

Phase What landed
8 One registration path. Generated [RegisterView] / [Route] apply without a required Map. [NotifyDependsOn]. CommunityToolkit ObservableObject interop. Analyzers MVVME001003.
9 IModule / AddModule<T>(). Modal stack. UseDeepLinks / UseSecureSessionAuth (fail closed). SectionHostView for in-place tabs. MvvmSearch.
10 Analyzers MVVME010013. 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 do
MVVME010 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, or Plugin.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 Nuvynnuvyn 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.


By Niladri

Top comments (0)