MVVM in .NET MAUI: Complete Guide with CommunityToolkit.Mvvm

·

·

If you’ve written MVVM by hand before — implementing INotifyPropertyChanged, wiring up ICommand, writing OnPropertyChanged for every field — you already know the pain. Now imagine cutting all of that boilerplate by 80% without losing control. That’s exactly what CommunityToolkit.Mvvm does for .NET MAUI developers.

This guide covers everything from setting up the toolkit to real-world patterns you’ll actually use in production MAUI apps.

Table of Contents

Why MVVM in .NET MAUI?

MVVM (Model-View-ViewModel) is the recommended architecture for .NET MAUI apps. It separates UI logic from business logic, making your code testable, maintainable, and reusable across platforms.

The Core Responsibilities

  • Model — raw data and business rules (DTOs, services, repositories)
  • View — XAML pages and controls that display data
  • ViewModel — exposes data and commands to the View via data binding

What Makes .NET MAUI Different

.NET MAUI targets iOS, Android, Windows, and macOS from one codebase. Your ViewModel runs on all platforms identically — only the XAML rendering differs. This makes MVVM especially powerful here: business logic is truly cross-platform.

Setting Up CommunityToolkit.Mvvm

Install the NuGet package into your MAUI project:

dotnet add package CommunityToolkit.Mvvm

Or via the NuGet Package Manager — search for CommunityToolkit.Mvvm. At the time of writing, version 8.x is current and fully supports .NET MAUI source generators.

Enable Source Generators

Source generators work automatically with the toolkit, but you need to mark your ViewModel classes as partial. This is mandatory — without it, the generator has nowhere to inject the generated code.

// ✅ Correct
public partial class MainViewModel : ObservableObject { }

// ❌ Won't work with source generators
public class MainViewModel : ObservableObject { }

Project File Requirements

Ensure your project targets .NET 8 or later and has nullable enabled — this is the standard for all new MAUI projects:

<PropertyGroup>
  <TargetFrameworks>net8.0-android;net8.0-ios;net8.0-maccatalyst;net8.0-windows10.0.19041.0</TargetFrameworks>
  <Nullable>enable</Nullable>
  <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>

ObservableObject and Source Generators

The base class for all your ViewModels is ObservableObject. It implements INotifyPropertyChanged and INotifyPropertyChanging for you, plus provides helper methods like SetProperty.

using CommunityToolkit.Mvvm.ComponentModel;

public partial class DashboardViewModel : ObservableObject
{
    // Properties and commands go here
}

What the Source Generator Produces

When you use the [ObservableProperty] attribute (covered next), the source generator creates a full property with OnPropertyChanged calls in a separate partial class file. You never see it, but it’s there — and it’s correct every time.

Verifying Generation in Visual Studio

In Solution Explorer → your project → Dependencies → Analyzers → CommunityToolkit.Mvvm.SourceGenerators, you’ll find the generated code. It’s worth inspecting once so you trust what’s happening under the hood.

ObservableProperty — The Boilerplate Killer

This is the feature that makes CommunityToolkit.Mvvm worth using. Instead of writing this:

private string _userName = string.Empty;
public string UserName
{
    get => _userName;
    set => SetProperty(ref _userName, value);
}

You write this:

[ObservableProperty]
private string _userName = string.Empty;

The source generator produces the full property automatically — including the OnUserNameChanged and OnUserNameChanging partial methods you can optionally override.

Reacting to Property Changes

public partial class ProfileViewModel : ObservableObject
{
    [ObservableProperty]
    private string _email = string.Empty;

    partial void OnEmailChanged(string value)
    {
        // Validate, trigger side effects, etc.
        IsEmailValid = value.Contains("@");
    }

    [ObservableProperty]
    private bool _isEmailValid;
}

NotifyPropertyChangedFor

If changing one property should notify another, use the attribute:

[ObservableProperty]
[NotifyPropertyChangedFor(nameof(FullName))]
private string _firstName = string.Empty;

[ObservableProperty]
[NotifyPropertyChangedFor(nameof(FullName))]
private string _lastName = string.Empty;

public string FullName => $"{FirstName} {LastName}";

RelayCommand and AsyncRelayCommand

[RelayCommand] turns a method into an ICommand automatically. No more new Command(...) wrappers.

public partial class LoginViewModel : ObservableObject
{
    [ObservableProperty]
    private string _username = string.Empty;

    [ObservableProperty]
    private string _password = string.Empty;

    [RelayCommand]
    private async Task LoginAsync()
    {
        // The generated command is named LoginCommand
        var result = await _authService.LoginAsync(Username, Password);
        if (!result.Success)
            ErrorMessage = result.Error;
    }

    [ObservableProperty]
    private string _errorMessage = string.Empty;
}

CanExecute with Commands

[RelayCommand(CanExecute = nameof(CanSubmit))]
private void Submit()
{
    // handle submit
}

private bool CanSubmit() => !string.IsNullOrEmpty(UserName);

Call SubmitCommand.NotifyCanExecuteChanged() whenever the condition changes to re-evaluate the button’s enabled state in the UI.

Cancellable Async Commands

[RelayCommand(IncludeCancelCommand = true)]
private async Task LoadDataAsync(CancellationToken token)
{
    var data = await _service.GetDataAsync(token);
    Items = new ObservableCollection<ItemModel>(data);
}

This generates both LoadDataCommand and CancelLoadDataCommand — bind the cancel button to the latter.

Messenger and WeakReferenceMessenger

ViewModels shouldn’t reference each other directly. Use the Messenger to communicate between them without coupling.

Defining a Message

public class UserLoggedInMessage : ValueChangedMessage<string>
{
    public UserLoggedInMessage(string userId) : base(userId) { }
}

Sending and Receiving

// Sending (e.g., LoginViewModel)
WeakReferenceMessenger.Default.Send(new UserLoggedInMessage(userId));

// Receiving (e.g., ShellViewModel)
public partial class ShellViewModel : ObservableRecipient
{
    protected override void OnActivated()
    {
        Messenger.Register<UserLoggedInMessage>(this, (r, m) =>
        {
            CurrentUserId = m.Value;
        });
    }
}

Using ObservableRecipient as the base class instead of ObservableObject enables the messenger integration. WeakReferenceMessenger holds weak references, so ViewModels are garbage collected normally — no memory leaks.

Dependency Injection with ViewModels

Register ViewModels and services in MauiProgram.cs:

public static class MauiProgram
{
    public static MauiApp CreateMauiApp()
    {
        var builder = MauiApp.CreateBuilder();
        builder.UseMaui();

        // Services
        builder.Services.AddSingleton<IAuthService, AuthService>();
        builder.Services.AddSingleton<IProductService, ProductService>();

        // ViewModels
        builder.Services.AddTransient<LoginViewModel>();
        builder.Services.AddTransient<DashboardViewModel>();

        // Pages
        builder.Services.AddTransient<LoginPage>();
        builder.Services.AddTransient<DashboardPage>();

        return builder.Build();
    }
}

Injecting into Pages

public partial class LoginPage : ContentPage
{
    public LoginPage(LoginViewModel vm)
    {
        InitializeComponent();
        BindingContext = vm;
    }
}

This is the cleanest pattern — the page receives its ViewModel from the DI container rather than creating it directly. [INTERNAL_LINK: .NET MAUI Dependency Injection full guide]

Real-World Example: Product Listing Screen

Here’s a complete ViewModel for a product list page:

public partial class ProductListViewModel : ObservableObject
{
    private readonly IProductService _productService;

    public ProductListViewModel(IProductService productService)
    {
        _productService = productService;
    }

    [ObservableProperty]
    private ObservableCollection<Product> _products = new();

    [ObservableProperty]
    private bool _isLoading;

    [ObservableProperty]
    private string _searchQuery = string.Empty;

    partial void OnSearchQueryChanged(string value)
    {
        SearchCommand.NotifyCanExecuteChanged();
    }

    [RelayCommand]
    private async Task LoadProductsAsync()
    {
        IsLoading = true;
        try
        {
            var items = await _productService.GetAllAsync();
            Products = new ObservableCollection<Product>(items);
        }
        finally
        {
            IsLoading = false;
        }
    }

    [RelayCommand(CanExecute = nameof(CanSearch))]
    private async Task SearchAsync()
    {
        IsLoading = true;
        var results = await _productService.SearchAsync(SearchQuery);
        Products = new ObservableCollection<Product>(results);
        IsLoading = false;
    }

    private bool CanSearch() => SearchQuery.Length >= 2;
}

The matching XAML would bind CollectionView to Products, an ActivityIndicator to IsLoading, and a search bar’s command to SearchCommand.

FAQ

Do I need CommunityToolkit.Mvvm or can I use a third-party framework like Prism?

CommunityToolkit.Mvvm is officially supported by Microsoft and has zero dependencies beyond .NET. Prism is more opinionated and includes navigation abstractions. For most apps, the toolkit is enough — reach for Prism only if you need its specific navigation or module system features.

Why must my ViewModel be a partial class?

The C# source generator emits code into a separate partial class file at compile time. If your class isn’t partial, there’s no way for the generated code to be merged with yours — you’ll get a compile error or the attribute will silently do nothing.

Can I use ObservableProperty with collections?

Yes, but be careful: if you replace the entire collection (Products = new ObservableCollection<>()), the binding updates. If you mutate the existing collection (add/remove items), use ObservableCollection<T> — it raises its own change notifications per item automatically.

What’s the difference between RelayCommand and AsyncRelayCommand?

The [RelayCommand] attribute detects whether the annotated method is async or synchronous and generates the appropriate command type automatically. You don’t manually choose — just write async Task methods and the generator picks AsyncRelayCommand for you.

How do I unit test ViewModels using CommunityToolkit.Mvvm?

ViewModels built with this toolkit are plain C# classes — inject mocked services, call commands directly via await vm.LoadProductsCommand.ExecuteAsync(null), and assert on property values. No UI framework is needed for ViewModel tests.

Can I combine ObservableRecipient and ObservableObject features?

ObservableRecipient already inherits from ObservableObject, so all attributes like [ObservableProperty] and [RelayCommand] work the same way. Just use ObservableRecipient as your base when you need messaging.

Conclusion

CommunityToolkit.Mvvm transforms MVVM in .NET MAUI from tedious to genuinely enjoyable. Source generators eliminate repetitive boilerplate, [ObservableProperty] makes your ViewModel code readable, and [RelayCommand] handles command wiring with zero ceremony.

The key habits to build: always use partial classes, inject services through constructors, and use WeakReferenceMessenger for cross-ViewModel communication. Follow these three rules and your MAUI app architecture will stay clean across features and team members.

[INTERNAL_LINK: .NET MAUI Shell Navigation guide] — once your ViewModels are solid, navigation is the next piece to get right.


Leave a Reply

Your email address will not be published. Required fields are marked *