WPF is twenty years old and quietly excellent. The tooling is mature, the rendering engine is fast, and once MVVM clicks the codebase practically writes itself. This tutorial builds a tiny "task list" app from scratch, focused on the three concepts that matter: data binding, commands, and view models.

The shape of an MVVM app

The view talks to the view model. The view model talks to the model. The view model never references UI types. Follow that and the rest sorts itself out.

1. The view model base

Every view model needs to raise PropertyChanged when bound values change. CommunityToolkit.Mvvm makes this trivial — install the NuGet package and inherit from ObservableObject:

using CommunityToolkit.Mvvm.ComponentModel;

public partial class TaskItemViewModel : ObservableObject
{
    [ObservableProperty]
    private string _title = "";

    [ObservableProperty]
    private bool _isDone;
}

The source generator turns those fields into full properties with change notification. No more boilerplate.

2. The list view model

public partial class TaskListViewModel : ObservableObject
{
    public ObservableCollection<TaskItemViewModel> Tasks { get; } = new();

    [ObservableProperty]
    private string _newTitle = "";

    [RelayCommand(CanExecute = nameof(CanAdd))]
    private void Add()
    {
        Tasks.Add(new TaskItemViewModel { Title = NewTitle });
        NewTitle = "";
    }

    private bool CanAdd() => !string.IsNullOrWhiteSpace(NewTitle);
}

The [RelayCommand] attribute generates an AddCommand property exposing an ICommand. Because we passed a CanExecute callback, the bound button automatically disables when the input is empty.

3. The view

<Window x:Class="TaskApp.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:vm="clr-namespace:TaskApp"
        Title="Tasks" Height="480" Width="360">
    <Window.DataContext>
        <vm:TaskListViewModel />
    </Window.DataContext>

    <DockPanel Margin="12">
        <Grid DockPanel.Dock="Top">
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="*" />
                <ColumnDefinition Width="Auto" />
            </Grid.ColumnDefinitions>
            <TextBox Text="{Binding NewTitle, UpdateSourceTrigger=PropertyChanged}" />
            <Button Grid.Column="1" Content="Add"
                    Command="{Binding AddCommand}" Margin="6,0,0,0"/>
        </Grid>

        <ItemsControl ItemsSource="{Binding Tasks}" Margin="0,12,0,0">
            <ItemsControl.ItemTemplate>
                <DataTemplate>
                    <CheckBox IsChecked="{Binding IsDone}"
                              Content="{Binding Title}" Margin="0,4"/>
                </DataTemplate>
            </ItemsControl.ItemTemplate>
        </ItemsControl>
    </DockPanel>
</Window>

That's the entire app. There's no code-behind beyond InitializeComponent(). Adding tasks, marking them done, disabling the Add button when the input is empty — all driven by bindings.

The two binding gotchas everyone hits

UpdateSourceTrigger

By default, TextBox bindings only push to the view model when focus leaves the control. That's why the Add button stays disabled until you tab away. Set UpdateSourceTrigger=PropertyChanged for live updates — as we did above.

The Output window is your friend

Binding errors don't crash the app — they print to the Visual Studio Output window. Watch for lines like:

System.Windows.Data Error: 40 : BindingExpression path error:
'Titel' property not found on object of type 'TaskItemViewModel'.

Typos in {Binding Path} are the most common WPF bug. Always check Output before assuming the framework is broken.

Beyond the basics

Pragmatic note. MVVM is a guideline, not a religion. If a piece of view-only state (like an animation trigger) lives in code-behind, that's fine. The rule is "no business logic in code-behind," not "no code in code-behind."

Where to go next

Clone the sample, add a Delete command, then a JSON file persistence layer. By the time you've done that, the patterns will be muscle memory and you can graduate to bigger apps with confidence.

Our desktop track covers WPF, WinUI 3, and migrating legacy WinForms apps. If you're modernizing an internal tool, we'd love to help.