The MVVM Pattern
In Phase 3 you wired a control to a property with {Binding} and hit the wall everyone hits: you changed the property in code, and the screen didn't budge. The binding fired once, at startup, and then went quiet. This phase is the fix — and it's also the moment your notes app stops being a toy and starts being structured like a real app.
The mental model: three roles, one rule
MVVM stands for Model–View–ViewModel. It sounds like architecture-astronaut jargon, but it's three plain ideas:
- View — your XAML page. It describes what's on screen. Nothing more.
- ViewModel — a plain C# class that holds the page's state (the data it shows) and its commands (what the buttons do). It knows nothing about XAML, buttons, or labels.
- Model — your actual data. For the notes app, that's a
Noteclass with aTitle.
The View binds to the ViewModel. You set the ViewModel as the page's BindingContext, and every {Binding Title} in the XAML reads from a Title property on the ViewModel. The arrows only point one way in your head: the View asks the ViewModel for data; the ViewModel never reaches up and touches the View.
💡 The whole payoff is in that last sentence. Because the ViewModel has no UI dependency, you can create one in a unit test, call its
AddNotecommand, and assert that the notes collection grew — no screen, no emulator, no tapping. A ViewModel you can test without a UI is the entire point of MVVM. If you remember one thing, remember that.
flowchart LR
V["View (XAML page)"] -- "BindingContext" --> VM["ViewModel (state + commands)"]
VM -- "holds" --> M["Model (Note data)"]
VM -. "raises PropertyChanged" .-> V
Hold "View shows, ViewModel decides, Model is the data" and everything below is detail.
Why the screen didn't update: INotifyPropertyChanged
A binding isn't magic. When you write {Binding Title}, MAUI reads Title once and shows it. For MAUI to re-read it when the value changes, the object holding Title has to announce the change. The contract for that announcement is an interface called INotifyPropertyChanged — one event, PropertyChanged, that you raise whenever a bound property's value changes.
Here's that contract done by hand, for a single Title property:
using System.ComponentModel;
public class NoteViewModel : INotifyPropertyChanged
{
private string _title = "";
public string Title
{
get => _title;
set
{
_title = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Title)));
}
}
public event PropertyChangedEventHandler? PropertyChanged;
}
What just happened: the property's setter doesn't only store the new value — it fires PropertyChanged, naming Title as the thing that changed. Any {Binding Title} in the XAML is subscribed to that event, hears its name called, and re-reads the property. That is the missing piece from Phase 3. The binding wasn't broken; the object just never told anyone its value had moved.
Now look at how much ceremony that is for one property: a backing field, a hand-written getter, a hand-written setter, the event invocation, and the nameof. Imagine a notes page with a title, a body, a "saving…" flag, and a selected note. You'd write that pattern four times, and one typo in a nameof gives you a silent bug where the screen mysteriously won't refresh for that one field. Nobody enjoys this. So nobody writes it by hand anymore.
The modern way: CommunityToolkit.Mvvm
CommunityToolkit.Mvvm is a small, official NuGet package that generates all that boilerplate for you at compile time using source generators. You add the package, inherit from ObservableObject, and annotate fields — the generator writes the properties and the PropertyChanged plumbing behind the scenes.
📝 You add it once per project: dotnet add package CommunityToolkit.Mvvm. The class must be partial so the generator can add the generated half.
Here's the real NotesViewModel for our app:
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
public partial class NotesViewModel : ObservableObject
{
[ObservableProperty]
private string newTitle = "";
public ObservableCollection<Note> Notes { get; } = new();
[RelayCommand]
private void AddNote()
{
Notes.Add(new Note { Title = NewTitle });
NewTitle = "";
}
}
What just happened: three pieces of magic, none of which you had to write:
[ObservableProperty]on the fieldnewTitlegenerates a public propertyNewTitle(capital N) — with the getter, setter, andPropertyChangednotification from the hand-written version above. You write one line; the generator writes fifteen.ObservableCollection<Note>is a list that itself raises change notifications. When youAddto it, any bound list on screen updates automatically — no extra code. (A plainList<Note>would not; the UI wouldn't see new items.)[RelayCommand]on the methodAddNotegenerates a public propertyAddNoteCommandof typeICommand. That's the thing your button will bind to.
Notice the symmetry with the hand-written class: ObservableObject is the INotifyPropertyChanged implementation, and [ObservableProperty] is the verbose setter — both handed to you for free.
Commands: buttons that talk to the ViewModel
In the old code-behind world, a button did its work through a Clicked event handler living next to the XAML:
<!-- The old way: logic lives in code-behind -->
That handler sits in the page's .xaml.cs file, which means your logic is tangled up with the View — exactly what MVVM is trying to separate. The MVVM way replaces the event with a command binding:
What just happened: the Entry's text is two-way bound to NewTitle, so as the user types, the ViewModel's NewTitle updates live. The Button's Command is bound to AddNoteCommand — the property [RelayCommand] generated from your AddNote method. Tap the button, the command runs AddNote, which adds a Note to the ObservableCollection and clears NewTitle. The CollectionView shows a new row instantly (the collection announced the add) and the Entry clears itself (the NewTitle setter announced the change). You never touched a code-behind handler. The View describes; the ViewModel decides.
To connect the two, you set the page's BindingContext to the ViewModel — typically in the page's constructor:
public partial class NotesPage : ContentPage
{
public NotesPage()
{
InitializeComponent();
BindingContext = new NotesViewModel();
}
}
What just happened: every unqualified {Binding ...} on the page now resolves against this NotesViewModel instance. That one line is the wire between the View and the ViewModel. (In a larger app you'd inject the ViewModel through MAUI's dependency injection rather than new it here, but the idea is identical: the page gets a ViewModel as its BindingContext.)
The one discipline that keeps it testable
⚠️ Here's the trap, and it's the most common MVVM mistake: the moment something is mildly inconvenient, you reach for a UI call inside the ViewModel. "I'll just pop a DisplayAlert to confirm the delete." Don't.
DisplayAlert, navigation, and other UI calls live on the page, not the ViewModel. The second your ViewModel calls DisplayAlert, it can no longer be constructed in a unit test — it depends on a live page — and you've thrown away the entire benefit. The same goes for stuffing logic back into code-behind: if real decisions live in .xaml.cs, you can't test them either.
The rule of thumb:
- State and decisions → ViewModel. ("What notes exist? Is the title empty? Add this note.")
- Showing a dialog, navigating, animating → these are UI concerns. Keep them on the page, or hand the ViewModel a small injected service (like
IAlertService) that you can swap for a fake in tests.
When you find yourself wanting UI in the ViewModel, that's the signal to extract a service — not to break the boundary. Guard that boundary and your ViewModels stay the cheap-to-test core of your app, exactly as designed.
Recap
- MVVM splits a screen into three roles: the View (XAML, shows things), the ViewModel (plain C# holding state and commands), and the Model (your data). The View binds to the ViewModel via
BindingContext. - A binding only refreshes when the object announces changes through
INotifyPropertyChanged— raisingPropertyChangedin setters. That's why the Phase 3 screen wouldn't update. - Doing that by hand is verbose and typo-prone. CommunityToolkit.Mvvm generates it: inherit
ObservableObject, mark fields[ObservableProperty], mark methods[RelayCommand]. - Bind a button's
Commandto a generated command (AddNoteCommand) instead of writing a code-behindClickedhandler. UseObservableCollection<T>so list changes show up automatically. - Keep UI concerns (
DisplayAlert, navigation) out of the ViewModel — that no-UI-dependency is what makes the ViewModel testable, and testability is the whole point.
Quick check
[
{
"q": "Your bound Label still shows the old value after you change the property in code. What's the most likely cause?",
"choices": ["The XAML is malformed", "The property's setter doesn't raise PropertyChanged (the class doesn't implement INotifyPropertyChanged)", "You forgot to call InitializeComponent", "Bindings only work with strings"],
"answer": 1,
"explain": "A binding re-reads a property only when the object announces the change via PropertyChanged. Without INotifyPropertyChanged, the UI never hears about updates."
},
{
"q": "What does [RelayCommand] on a method named AddNote generate?",
"choices": ["A backing field named addNote", "A public ICommand property named AddNoteCommand that a Button can bind to", "An event handler in the code-behind", "Nothing — it's just documentation"],
"answer": 1,
"explain": "[RelayCommand] generates an ICommand property (method name + \"Command\"), so you bind Command=\"{Binding AddNoteCommand}\" instead of writing a Clicked handler."
},
{
"q": "Why should a ViewModel avoid calling DisplayAlert directly?",
"choices": ["DisplayAlert is deprecated in MAUI", "It makes the app slower", "It ties the ViewModel to a live UI, so it can no longer be unit-tested without a page", "Alerts can only be shown from XAML"],
"answer": 2,
"explain": "The point of MVVM is a ViewModel with no UI dependency. A direct DisplayAlert call requires a live page, which breaks testability. Inject a service instead."
}
]
← Phase 3: Controls & Data Binding · Guide overview · Phase 5: Navigation with Shell →
Check your understanding
1. Your bound Label still shows the old value after you change the property in code. What's the most likely cause?
2. What does [RelayCommand] on a method named AddNote generate?
3. Why should a ViewModel avoid calling DisplayAlert directly?