DEV Community

Manohari Jayachandran
Manohari Jayachandran

Posted on

MVVM Explained: Refactoring the Task Tracker, Properly

Part 1 built a working Task Tracker in plain WPF, and closed by naming three pain points left visible on purpose, business logic tangled directly with UI code, a manual refresh step that was easy to forget (and one place where it actually was forgotten), and zero ability to unit test the filtering logic without running the full application. This post refactors that exact same app into proper MVVM, fixing all three, one at a time.

What MVVM Actually Is

MVVM stands for Model-View-ViewModel, a pattern that splits an application into three distinct roles, each with exactly one job, so that UI code and business logic never end up tangled together the way they were in Part 1.

The Model is what a Task actually is, its data, Description and IsCompleted, with no UI knowledge whatsoever. The View is the XAML, purely visual, it knows how to display things, and nothing about what a "task" means or how filtering works. The ViewModel is the middleman, it holds the tasks, knows how filtering works, and exposes everything the View needs to bind to, but has no direct reference to any XAML control at all.

Think of a restaurant. The Model is the food itself, the actual dish, made of actual ingredients. The View is the plate and table setting, how it's presented to the customer, purely visual. The ViewModel is the waiter, the one who actually knows what's in the kitchen, takes the order, and carries information back and forth between the kitchen and the table, without the plate ever needing to know how the dish was cooked, and without the kitchen ever needing to know which table it's going to.

The Model: Just Data, Nothing Else

// TaskItem.cs - unchanged in SHAPE from Part 1, but now
// implements INotifyPropertyChanged, covered next
public class TaskItem : INotifyPropertyChanged
{
    private string _description;
    private bool _isCompleted;

    public string Description
    {
        get => _description;
        set { _description = value; OnPropertyChanged(); }
    }

    public bool IsCompleted
    {
        get => _isCompleted;
        set { _isCompleted = value; OnPropertyChanged(); }
    }

    public event PropertyChangedEventHandler PropertyChanged;
    protected void OnPropertyChanged([CallerMemberName] string name = null)
        => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
Enter fullscreen mode Exit fullscreen mode

INotifyPropertyChanged: How the UI Finds Out Something Changed

In Part 1, updating the UI after a change required manually calling RefreshList(), a step that was easy to forget, and genuinely was forgotten in the checkbox handler. INotifyPropertyChanged fixes this at the source: whenever a property's value actually changes, it raises an event announcing exactly that. The UI, bound to that property, is listening for this event and updates itself automatically, nobody has to remember to tell it anything.

Think of a doorbell versus needing to personally walk around telling every single person in a house that someone arrived. Ring the doorbell once, raise the event, anyone who cares, anyone bound or listening, hears it and reacts automatically, without you tracking down each person individually.

The ViewModel: Where the Actual Logic Now Lives

public class TaskViewModel : INotifyPropertyChanged
{
    private ObservableCollection<TaskItem> _allTasks = new();
    private ObservableCollection<TaskItem> _visibleTasks = new();
    private string _newTaskDescription;
    private string _currentFilter = "All";

    public ObservableCollection<TaskItem> VisibleTasks
    {
        get => _visibleTasks;
        private set { _visibleTasks = value; OnPropertyChanged(); }
    }

    public string NewTaskDescription
    {
        get => _newTaskDescription;
        set { _newTaskDescription = value; OnPropertyChanged(); }
    }

    public ICommand AddTaskCommand { get; }
    public ICommand DeleteTaskCommand { get; }
    public ICommand ShowAllCommand { get; }
    public ICommand ShowActiveCommand { get; }
    public ICommand ShowCompletedCommand { get; }

    public TaskViewModel()
    {
        AddTaskCommand = new RelayCommand(AddTask);
        DeleteTaskCommand = new RelayCommand<TaskItem>(DeleteTask);
        ShowAllCommand = new RelayCommand(() => ApplyFilter("All"));
        ShowActiveCommand = new RelayCommand(() => ApplyFilter("Active"));
        ShowCompletedCommand = new RelayCommand(() => ApplyFilter("Completed"));
    }

    private void AddTask()
    {
        if (!string.IsNullOrWhiteSpace(NewTaskDescription))
        {
            var task = new TaskItem { Description = NewTaskDescription, IsCompleted = false };
            task.PropertyChanged += (s, e) => ApplyFilter(_currentFilter);
            // ^ this ViewModel LISTENS to each task's own
            // PropertyChanged - so checking a box automatically
            // re-applies the current filter, fixing the EXACT
            // bug from Part 1 where checking a box didn't
            // update the Completed filter until a button was
            // clicked again

            _allTasks.Add(task);
            NewTaskDescription = string.Empty;
            ApplyFilter(_currentFilter);
        }
    }

    private void DeleteTask(TaskItem task)
    {
        _allTasks.Remove(task);
        ApplyFilter(_currentFilter);
    }

    private void ApplyFilter(string filter)
    {
        _currentFilter = filter;
        var filtered = filter switch
        {
            "Active" => _allTasks.Where(t => !t.IsCompleted),
            "Completed" => _allTasks.Where(t => t.IsCompleted),
            _ => _allTasks.AsEnumerable()
        };
        VisibleTasks = new ObservableCollection<TaskItem>(filtered);
    }

    public event PropertyChangedEventHandler PropertyChanged;
    protected void OnPropertyChanged([CallerMemberName] string name = null)
        => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
Enter fullscreen mode Exit fullscreen mode

Notice what's genuinely absent: no TaskListBox, no TaskInput.Text, no reference to any XAML control anywhere in this entire class. Every single method here operates purely on data and logic, which is exactly what makes it unit-testable, covered further below.

ICommand: Replacing Click Event Handlers

In Part 1, a button's Click="AddTask_Click" called a method directly in code-behind. In MVVM, a button binds to an ICommand property on the ViewModel instead, the View never calls a method directly at all.

// RelayCommand.cs - a small, reusable ICommand
// implementation (a common, standard helper class)
public class RelayCommand : ICommand
{
    private readonly Action _execute;
    private readonly Func<bool> _canExecute;

    public RelayCommand(Action execute, Func<bool> canExecute = null)
    {
        _execute = execute;
        _canExecute = canExecute;
    }

    public bool CanExecute(object parameter) => _canExecute?.Invoke() ?? true;
    public void Execute(object parameter) => _execute();
    public event EventHandler CanExecuteChanged
    {
        add => CommandManager.RequerySuggested += value;
        remove => CommandManager.RequerySuggested -= value;
    }
}

// A generic version for commands needing a parameter,
// like DeleteTaskCommand needing to know WHICH task
public class RelayCommand<T> : ICommand
{
    private readonly Action<T> _execute;
    public RelayCommand(Action<T> execute) => _execute = execute;
    public bool CanExecute(object parameter) => true;
    public void Execute(object parameter) => _execute((T)parameter);
    public event EventHandler CanExecuteChanged;
}
Enter fullscreen mode Exit fullscreen mode

The View: Now Purely Visual

<Window x:Class="TaskTracker.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="Task Tracker" Height="450" Width="400">

    <Window.DataContext>
        <local:TaskViewModel />
    </Window.DataContext>

    <DockPanel Margin="10">

        <StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Margin="0,0,0,10">
            <TextBox Text="{Binding NewTaskDescription, UpdateSourceTrigger=PropertyChanged}" Width="250" Margin="0,0,10,0" />
            <Button Content="Add Task" Command="{Binding AddTaskCommand}" />
        </StackPanel>

        <StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Center">
            <Button Content="All" Command="{Binding ShowAllCommand}" Margin="5" />
            <Button Content="Active" Command="{Binding ShowActiveCommand}" Margin="5" />
            <Button Content="Completed" Command="{Binding ShowCompletedCommand}" Margin="5" />
        </StackPanel>

        <ListBox ItemsSource="{Binding VisibleTasks}">
            <ListBox.ItemTemplate>
                <DataTemplate>
                    <StackPanel Orientation="Horizontal">
                        <CheckBox IsChecked="{Binding IsCompleted}" />
                        <TextBlock Text="{Binding Description}" Margin="10,0,0,0" />
                        <Button Content="Delete"
                                Command="{Binding DataContext.DeleteTaskCommand,
                                          RelativeSource={RelativeSource AncestorType=Window}}"
                                CommandParameter="{Binding}"
                                Margin="20,0,0,0" />
                    </StackPanel>
                </DataTemplate>
            </ListBox.ItemTemplate>
        </ListBox>

    </DockPanel>
</Window>
Enter fullscreen mode Exit fullscreen mode

Notice: zero Click= or Checked= event handlers anywhere. Every interaction is a binding or a command. This XAML file has no paired logic in MainWindow.xaml.cs at all, the code-behind file is now essentially empty.

The Three Pain Points From Part 1, Fixed Directly

Pain point one was business logic tangled with UI code. This is fixed: AddTask, DeleteTask, and ApplyFilter live entirely in TaskViewModel, with zero references to any XAML control. The View contains no logic at all.

Pain point two was the manual RefreshList() that had to be remembered. This is fixed: INotifyPropertyChanged means the UI updates itself the moment VisibleTasks changes, or the moment any individual TaskItem's IsCompleted changes. There's no refresh step to forget, because there's no refresh step at all, the exact checkbox bug from Part 1 is now structurally impossible, not just fixed by remembering harder next time.

Pain point three was that the filtering logic couldn't be unit tested. This is fixed too, covered next.

The Real Payoff: Genuinely Unit-Testable Filtering Logic

public class TaskViewModelTests
{
    [Fact]
    public void ApplyFilter_Active_ReturnsOnlyIncompleteTasks()
    {
        // Arrange - no WPF, no Window, no UI of any
        // kind involved anywhere in this test
        var viewModel = new TaskViewModel();
        viewModel.NewTaskDescription = "Task A";
        viewModel.AddTaskCommand.Execute(null);
        viewModel.NewTaskDescription = "Task B";
        viewModel.AddTaskCommand.Execute(null);
        viewModel.VisibleTasks[0].IsCompleted = true;

        // Act
        viewModel.ShowActiveCommand.Execute(null);

        // Assert
        Assert.Single(viewModel.VisibleTasks);
        Assert.Equal("Task B", viewModel.VisibleTasks[0].Description);
    }
}
Enter fullscreen mode Exit fullscreen mode

This is exactly the kind of test that was impossible in Part 1, filtering logic lived directly inside a Click handler, tightly coupled to TaskListBox. Here, it's plain C# logic on a plain class, tested the same way as everything covered in the earlier unit testing post on this blog.

Key Lessons

Model, View, and ViewModel each have exactly one job, data, visual presentation, and the logic connecting them, and MVVM's real value is keeping those three jobs from ever tangling back together.

INotifyPropertyChanged replaces manual refresh calls with an event the UI listens for automatically, the exact missed-refresh bug from Part 1 becomes structurally impossible, not just less likely.

ICommand replaces Click event handlers with bindable properties on the ViewModel, meaning the View never directly calls a method, it only ever binds to something.

The genuine payoff of this whole refactor is testability, the same filtering logic that required running the full WPF app and clicking buttons in Part 1 is now a plain, fast unit test with zero UI involved.

MVVM isn't extra complexity for its own sake, every piece introduced here directly fixes a specific, named problem from the plain code-behind version.

What's Next

Part 3 shifts to the web, Razor syntax fundamentals, the templating language Blazor is built on top of, covered next before Blazor itself in Part 4.

Summary

MVVM splits an app into a Model (plain data), a View (purely visual XAML), and a ViewModel (the logic connecting them, with no knowledge of any specific UI control), the restaurant analogy of dish, plate, and waiter. Refactoring the exact Task Tracker from Part 1 into this shape fixed every pain point named there directly: tangled logic separated cleanly, the missed-refresh bug eliminated structurally through INotifyPropertyChanged, and the filtering logic made genuinely unit-testable for the first time. Nothing here was added for its own sake, every piece of MVVM introduced in this post exists because a specific, real problem from the plain version needed exactly that fix.


More from TechStack Blog: C# / .NET: https://www.techstackblog.com/category.html?cat=csharp
CS Fundamentals: https://www.techstackblog.com/category.html?cat=cs-fundamentals

Top comments (0)