MVVM from Scratch

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

The MVVM Toolkit makes view models short, but it also hides the machinery. This page builds that machinery by hand, in about a hundred lines, so that the generated code in The MVVM Toolkit stops being magic: a base class, two command classes and a BooksViewModel for the Bookshelf Desktop app, followed by the mistakes people make when they write this code themselves. The page was written against .NET 10 LTS (latest patch 10.0.13) with C# 14, Visual Studio 2026 18.11.1, Windows App SDK 2.5.1 and CommunityToolkit.Mvvm 8.4.2 on Windows 10 and Windows 11. .NET 11 is at release candidate 2 (11.0.0-rc.2) and general availability is expected at .NET Conf, 10 to 13 November 2026; none of the APIs below is listed as changed for it at the time of writing.

Read The MVVM Pattern first for the roles of the view, view model and model; this page covers the smallest set of types that makes data binding and commands work, and the failure modes of each. The code is WPF-first.

Everything in this page is a user pattern: the base libraries give you the interfaces (INotifyPropertyChanged, ICommand), the attribute (CallerMemberName) and the weak-event classes, but they do not give you a ready-made ViewModelBase, RelayCommand or AsyncCommand. Microsoft Learn documents the interfaces and shows a property-change example; the base class and the command classes below are the common community shapes, written for this guide.

The ViewModelBase

INotifyPropertyChanged has a single member, an event. The documentation example declares the event and an OnPropertyChanged method that takes the caller’s name, and every setter calls it. The base class below adds the one thing every setter needs, SetProperty: compare, assign, notify, and report whether anything changed.

using System.ComponentModel;
using System.Runtime.CompilerServices;

namespace Bookshelf.Desktop.ViewModels;

public abstract class ViewModelBase : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler? PropertyChanged;

    protected void OnPropertyChanged([CallerMemberName] string? propertyName = null)
        => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));

    // Returns true when the value changed, so callers can run follow-up work.
    protected bool SetProperty<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
    {
        if (EqualityComparer<T>.Default.Equals(field, value))
        {
            return false;
        }

        field = value;
        OnPropertyChanged(propertyName);
        return true;
    }
}

SetProperty is a user pattern (the documentation shows only OnPropertyChanged); it mirrors ObservableObject.SetProperty.

How CallerMemberName works

[CallerMemberName] applies to an optional parameter with an explicit default. The compiler substitutes the name of the calling member at compile time, so renaming a property with a refactoring tool keeps the notification correct. In a constructor the value is ".ctor", in a static constructor ".cctor", and with no containing member it is the parameter default (null here). A constructor should therefore assign backing fields directly, and a method that changes several properties must pass explicit names: OnPropertyChanged(nameof(Title)).

A model wrapper and a computed property

The canonical Book is an immutable positional record, so a bindable item wraps it. This is the hand-written counterpart of the toolkit’s BookViewModel (Year and Price read through in the same way, omitted here).

using Bookshelf.Core;

namespace Bookshelf.Desktop.ViewModels;

public sealed class BookViewModel(Book book) : ViewModelBase
{
    private bool isFavorite;

    public Book Model { get; } = book;
    public int Id => Model.Id;
    public string Title => Model.Title;
    public string Author => Model.Author;

    public bool IsFavorite
    {
        get => isFavorite;
        set => SetProperty(ref isFavorite, value);
    }
}

When a property depends on another, the setter of the source raises the dependent name too: if (SetProperty(ref title, value)) { OnPropertyChanged(nameof(DisplayName)); }.

The commands

ICommand has Execute(object?), CanExecute(object?) and the CanExecuteChanged event that tells the command source (a button) to ask again. Nothing makes the event fire by itself, which is the central difficulty.

RelayCommand

A relay command forwards to delegates. This version raises CanExecuteChanged explicitly, so it is independent of WPF and easy to unit test. The generic variant receives the command parameter.

using System.Windows.Input;

namespace Bookshelf.Desktop.ViewModels;

public sealed class RelayCommand(Action execute, Func<bool>? canExecute = null) : ICommand
{
    public event EventHandler? CanExecuteChanged;
    public bool CanExecute(object? parameter) => canExecute?.Invoke() ?? true;
    public void Execute(object? parameter) => execute();
    public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}

public sealed class RelayCommand<T>(Action<T?> execute, Predicate<T?>? canExecute = null) : ICommand
{
    public event EventHandler? CanExecuteChanged;
    public bool CanExecute(object? parameter) => canExecute?.Invoke(Cast(parameter)) ?? true;
    public void Execute(object? parameter) => execute(Cast(parameter));
    public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);

    // A wrong parameter type throws InvalidCastException; null maps to default.
    private static T? Cast(object? parameter) => parameter is null ? default : (T)parameter;
}

The documentation lists the three members of ICommand and the WPF RoutedCommand implementations; a delegate-based relay command is not part of .NET and is described in the docs only as the toolkit’s RelayCommand.

AsyncCommand

Most Bookshelf Desktop actions touch the repository, so most commands are asynchronous. ICommand.Execute returns void, which is the trap: the obvious implementation is an async void method, and exceptions then escape to the dispatcher. The command below keeps the void at the interface boundary only, starts a Task, and routes every failure to a handler the caller must supply. It also disables itself while running, which prevents double-clicks and overlapping runs, and exposes cancellation.

using System.Windows.Input;

namespace Bookshelf.Desktop.ViewModels;

public sealed class AsyncCommand(
    Func<CancellationToken, Task> execute, Action<Exception> onError, Func<bool>? canExecute = null) : ICommand
{
    private CancellationTokenSource? cts;

    public event EventHandler? CanExecuteChanged;
    public bool IsRunning { get; private set; }

    public bool CanExecute(object? parameter) => !IsRunning && (canExecute?.Invoke() ?? true);

    // The only "fire and forget" in the type: failures never leave ExecuteAsync.
    public void Execute(object? parameter) => _ = ExecuteAsync();

    // Awaitable entry point for tests.
    public async Task ExecuteAsync()
    {
        if (!CanExecute(null)) return;

        using var source = cts = new CancellationTokenSource();
        SetRunning(true);
        try { await execute(source.Token); }
        catch (OperationCanceledException) { /* cancellation is a normal outcome */ }
        catch (Exception ex) { onError(ex); }
        finally { cts = null; SetRunning(false); }
    }

    public void Cancel() => cts?.Cancel();
    public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);

    private void SetRunning(bool value) { IsRunning = value; RaiseCanExecuteChanged(); }
}

Start the command on the UI thread: its await then resumes on the captured context, so CanExecuteChanged is raised on the UI thread, as command sources require. IsRunning is a plain property; to bind a progress ring to it the command would also need INotifyPropertyChanged, which the toolkit’s AsyncRelayCommand provides.

A BooksViewModel built by hand

The view model below uses the canonical shapes: Book (a record), IBookRepository and IDialogService, with the member names the other pages use (Books, SelectedBook, SearchText, StatusMessage and five commands). It has an ordinary constructor instead of the primary constructor used in the toolkit version, because the commands need this and a field initializer cannot refer to instance members.

using System.Collections.ObjectModel;
using Bookshelf.Core;

namespace Bookshelf.Desktop.ViewModels;

public sealed class BooksViewModel : ViewModelBase
{
    private readonly IBookRepository repository;
    private readonly IDialogService dialogs;
    private BookViewModel? selectedBook;
    private string searchText = string.Empty;
    private string statusMessage = "Ready";

    public BooksViewModel(IBookRepository repository, IDialogService dialogs)
    {
        this.repository = repository;
        this.dialogs = dialogs;

        LoadCommand = new AsyncCommand(LoadAsync, ReportError);
        AddBookCommand = new AsyncCommand(AddBookAsync, ReportError);
        EditSelectedCommand = new AsyncCommand(EditSelectedAsync, ReportError, () => SelectedBook is not null);
        DeleteSelectedCommand = new AsyncCommand(DeleteSelectedAsync, ReportError, () => SelectedBook is not null);
        ToggleFavoriteCommand = new RelayCommand<BookViewModel>(b => { if (b is not null) b.IsFavorite = !b.IsFavorite; });
        CancelLoadCommand = new RelayCommand(LoadCommand.Cancel, () => LoadCommand.IsRunning);
        LoadCommand.CanExecuteChanged += (_, _) => CancelLoadCommand.RaiseCanExecuteChanged();
    }

    public ObservableCollection<BookViewModel> Books { get; } = [];

    public BookViewModel? SelectedBook
    {
        get => selectedBook;
        set
        {
            if (SetProperty(ref selectedBook, value))
            {
                // CanExecute depends on SelectedBook, so tell the commands to ask again.
                EditSelectedCommand.RaiseCanExecuteChanged();
                DeleteSelectedCommand.RaiseCanExecuteChanged();
            }
        }
    }

    public string SearchText { get => searchText; set => SetProperty(ref searchText, value); }

    public string StatusMessage { get => statusMessage; private set => SetProperty(ref statusMessage, value); }

    public AsyncCommand LoadCommand { get; }
    public AsyncCommand AddBookCommand { get; }
    public AsyncCommand EditSelectedCommand { get; }
    public AsyncCommand DeleteSelectedCommand { get; }
    public RelayCommand<BookViewModel> ToggleFavoriteCommand { get; }
    public RelayCommand CancelLoadCommand { get; }

    private async Task LoadAsync(CancellationToken ct)
    {
        StatusMessage = "Loading...";

        var all = await repository.GetAllAsync(ct);
        Books.Clear();
        foreach (var book in all.Where(b => b.Title.Contains(SearchText, StringComparison.OrdinalIgnoreCase)))
        {
            Books.Add(new BookViewModel(book));
        }

        StatusMessage = $"{Books.Count} books";
    }

    private async Task AddBookAsync(CancellationToken ct)
    {
        var draft = await dialogs.EditBookAsync(null);
        if (draft is null) return; // cancelled

        Books.Add(new BookViewModel(await repository.AddAsync(draft, ct)));
    }

    private async Task EditSelectedAsync(CancellationToken ct)
    {
        var current = SelectedBook;
        var edited = current is null ? null : await dialogs.EditBookAsync(current.Model);
        if (current is null || edited is null) return;

        await repository.UpdateAsync(edited, ct);
        Books[Books.IndexOf(current)] = new BookViewModel(edited);
    }

    private async Task DeleteSelectedAsync(CancellationToken ct)
    {
        var current = SelectedBook;
        if (current is null || !await dialogs.ConfirmAsync("Delete book", $"Delete '{current.Title}'?")) return;

        await repository.DeleteAsync(current.Id, ct);
        Books.Remove(current);
    }

    private void ReportError(Exception ex) => StatusMessage = $"Error: {ex.Message}";
}

CancelLoadCommand depends on LoadCommand.IsRunning, so the constructor forwards the load command’s CanExecuteChanged to it. The view binds with ordinary markup (Command="{Binding LoadCommand}", ItemsSource="{Binding Books}", SelectedItem="{Binding SelectedBook}"); the DataContext is assigned in code or by the Generic Host (see Dependency Injection & the Generic Host).

Pitfalls

async void

async void is allowed for event handlers only, because an event signature returns void. Elsewhere the caller cannot await the work, cannot know when it finished and cannot catch its exception, which is raised on the SynchronizationContext current when the method started: in WPF the dispatcher, where an unhandled exception ends the process unless DispatcherUnhandledException handles it.

Three shapes to avoid or prefer: a command delegate declared async void (exceptions crash the app, tests cannot await it, two quick clicks run two overlapping loads); a delegate that calls LoadAsync().Wait() or reads .Result (blocks the UI thread and can deadlock when the awaited code needs it to resume); and a Func<CancellationToken, Task> run by an AsyncCommand that catches everything (correct: the exception becomes a status message and tests await ExecuteAsync()).

The same rule applies to the partial void OnXxxChanged hooks the toolkit generates: they are void, so async work started there needs its own try/catch. Threading, Dispatcher & Async covers the dispatcher side in detail.

CanExecute refresh

A button asks CanExecute when it is created, when its Command or CommandParameter changes and whenever the command raises CanExecuteChanged; if the underlying state changes and nothing raises the event, it keeps the old state. There are two ways out. The explicit event (used above) has the view model call RaiseCanExecuteChanged() when the inputs change, for example from the SelectedBook setter: one line per dependency that is easy to forget, but precise, cheap and independent of WPF. Forwarding to CommandManager.RequerySuggested (WPF only) makes WPF re-evaluate every bound command after focus, keyboard and mouse changes: nothing to remember for UI-driven changes, but CanExecute runs often and so must be cheap, and changes with no UI input (a timer, a background task) need CommandManager.InvalidateRequerySuggested().

The WPF variant changes only the event of the relay command:

public event EventHandler? CanExecuteChanged
{
    add => CommandManager.RequerySuggested += value;
    remove => CommandManager.RequerySuggested -= value;
}

The event documentation states that it holds the handler as a weak reference and that listeners should keep a strong reference to their handler. WPF’s own command sources such as Button are the intended consumers of this forwarding, and it is the usual way to use such a command with WPF controls; a custom class that subscribes its own lambda is not safe, because the lambda can be collected and the subscription silently stops. The documentation shows no view-model command built this way, so this is a community pattern. See Commands for the routed-command side of CommandManager.

Event handlers keep objects alive

A normal subscription with += stores a delegate in the event source, and the delegate references the subscriber. If the source lives longer than the subscriber, the subscriber cannot be collected. In Bookshelf Desktop a singleton BooksViewModel lives as long as the app, while a window-scoped StatusBarViewModel that does books.PropertyChanged += OnBooksChanged in its constructor is created and discarded many times, and each instance stays reachable and keeps receiving notifications. StatusBarViewModel is an illustrative type, not part of the canonical set. Three remedies, in order of preference: do not subscribe (bind the view to the long-lived object directly, because most such code only copies a value); unsubscribe deterministically with -= in Dispose or when the view unloads (needs a clear owner and a matching -= for every +=); or use a weak event when the source outlives its listeners and there is no reliable moment to unsubscribe.

Weak events with WeakEventManager

WeakEventManager<TEventSource,TEventArgs> is the type-safe form of the weak event pattern. The central manager holds the listener weakly, so the source no longer keeps it alive. AddHandler takes the source, the event name as a string and the handler, and checks the types at compile time.

using System.ComponentModel;
using System.Windows;

namespace Bookshelf.Desktop.ViewModels;

public sealed class StatusBarViewModel : ViewModelBase
{
    private readonly BooksViewModel books;

    public StatusBarViewModel(BooksViewModel books)
    {
        this.books = books;
        WeakEventManager<BooksViewModel, PropertyChangedEventArgs>
            .AddHandler(books, nameof(BooksViewModel.PropertyChanged), OnBooksChanged);
    }

    private void OnBooksChanged(object? sender, PropertyChangedEventArgs e) => OnPropertyChanged(nameof(Text));

    public string Text => books.StatusMessage;
}

WeakEventManager lives in System.Windows (WindowsBase.dll), so it is a WPF API; WinUI 3 and Windows Forms have no equivalent, and the toolkit’s WeakReferenceMessenger is the usual replacement for view-model-to-view-model notifications (see Messaging Between View Models). The manager derives from DispatcherObject, so register on the thread that owns it. The event name is a string the compiler does not check. RemoveHandler(source, eventName, handler) exists for owners that know their end of life; specialized managers such as PropertyChangedEventManager and CollectionChangedEventManager cover the common events.

PropertyChanged with an empty name

PropertyChangedEventArgs.PropertyName documents that an empty string or null means all properties changed, and WPF bindings then refresh every property of the source. That is useful after a bulk change and a trap after a bug:

  • OnPropertyChanged(null) or OnPropertyChanged("") is legal and expensive: every binding on the object re-reads its property. A [CallerMemberName] call outside any member passes the default, null, which also means "everything".

  • A misspelled name matches no property and nothing refreshes: use nameof(...). Raise the event after assigning the field, never before, or a binding that re-reads synchronously sees the old value.

Compared with what the toolkit generates

CommunityToolkit.Mvvm replaces the hand-written types with a base class, command classes and source generators. The toolkit documentation shows that an annotated field generates a setter that compares with EqualityComparer<T>.Default, calls the optional OnNameChanging / OnNameChanged hooks, and raises PropertyChanging and PropertyChanged around the assignment. With 8.4 you write the partial property form [ObservableProperty] public partial string? Name { get; set; } and the generator emits the same logic; the official page still shows only the field form, so that statement rests on the 8.4.0 release notes (see The MVVM Toolkit).

Concern Hand-written (this page) CommunityToolkit.Mvvm 8.4.2

Base class

ViewModelBase with PropertyChanged, OnPropertyChanged, SetProperty<T>.

ObservableObject: INotifyPropertyChanged and INotifyPropertyChanging, several SetProperty overloads (including the model-wrapping overload and SetPropertyAndNotifyOnCompletion for Task properties).

A property

Backing field, getter, setter calling SetProperty.

One [ObservableProperty] partial property; the generator writes the setter, the change hooks and the notifications.

Command refresh

Manual RaiseCanExecuteChanged() or RequerySuggested.

NotifyCanExecuteChanged() and [NotifyCanExecuteChangedFor(nameof(Command))]; no RequerySuggested dependency.

Async state

IsRunning as a plain property; own Cancel().

IsRunning, ExecutionTask, CanBeCanceled, IsCancellationRequested and Cancel() on IAsyncRelayCommand.

Writing the types yourself is reasonable when you cannot add a package or want to read how it works once. Otherwise prefer the toolkit: it is tested, covers cancellation and concurrency policy, and removes the repetitive code where the bugs in this page come from.

WPF, WinUI 3 and Windows Forms

INotifyPropertyChanged and ICommand are the same contracts on all three stacks; the differences are around them:

Concern WPF WinUI 3 Windows Forms

Binding to the view model

{Binding}.

{x:Bind ViewModel.Prop, Mode=OneWay} or {Binding}.

BindingSource and data binding.

Command on controls

Button, MenuItem and others.

Button and others.

ButtonBase, ToolStripItem since .NET 7 (see Commands).

CommandManager, WeakEventManager

Available.

Not available; raise CanExecuteChanged explicitly.

Not available.

The explicit-event commands are the portable choice. The "Not available" cells come from the absence of those types in the platform API; the documentation does not state the difference in one sentence.

What changed over the years

[CallerMemberName] arrived with C# 5 and .NET Framework 4.5; nullable annotations (C# 8) made the event PropertyChangedEventHandler? and the name parameter string?; primary constructors (C# 12) shorten the types above. The interfaces and WeakEventManager are unchanged from .NET 8 to .NET 10. With MVVM Toolkit 8.4 and C# 14, [ObservableProperty] works on partial properties (the generators need Roslyn 5 from 8.4.1).

What the documentation does not provide

  • No Microsoft Learn article describes MVVM for WPF specifically, and no official ViewModelBase, RelayCommand or AsyncCommand exists outside the toolkit; the code here is a pattern.

  • The ICommand page does not discuss async execution, and RequerySuggested has no view-model example.

  • ObservableCollection<T> changes from a background thread throw; see Threading, Dispatcher & Async.