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)).
Source: CallerMemberNameAttribute class
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;
}
Source: ICommand interface
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(); }
}
Source: ICommand interface
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)orOnPropertyChanged("")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 |
|
|
A property |
Backing field, getter, setter calling |
One |
Command refresh |
Manual |
|
Async state |
|
|
Source: AsyncRelayCommand
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 |
|
|
|
|
|
|
|
|
Available. |
Not available; raise |
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,RelayCommandorAsyncCommandexists outside the toolkit; the code here is a pattern. -
The
ICommandpage does not discussasyncexecution, andRequerySuggestedhas no view-model example. -
ObservableCollection<T>changes from a background thread throw; see Threading, Dispatcher & Async.
References
-
Data Binding for
DataContext, binding modes andObservableCollection<T> -
Commands for
ICommand, routed commands andCommandManager -
Threading, Dispatcher & Async for the dispatcher and
async void -
Validation for
INotifyDataErrorInfo, which a hand-written base class can also implement