Building Your First MVVM Application: A Complete Guide to ViewModels and Commands in Avalonia UI

If you’ve been following along with our Avalonia journey, you’ve mastered the basics of creating projects and understanding the framework. Now it’s time to dive into the architectural pattern that will make your applications maintainable, testable, and scalable: Model-View-ViewModel (MVVM).

In this hands-on tutorial, we’ll build a practical todo application that demonstrates proper MVVM implementation using Community Toolkit MVVM. By the end, you’ll understand how to create ViewModels, implement commands, and maintain clean separation of concerns that professional developers swear by.

What You’ll Build Today

We’re creating a simple yet complete todo application featuring:

  • Add new tasks with a clean interface
  • Mark tasks as complete/incomplete
  • Delete unwanted tasks
  • Proper data binding throughout
  • Command-based interactions

Setting Up Your MVVM Project in Rider

Fire up JetBrains Rider and create a new Avalonia project with MVVM template. I’ll assume you know the drill from our previous articles, so let’s jump straight to the package installation.

This package is your best friend for MVVM in .NET. It provides source generators that eliminate boilerplate code and makes your ViewModels incredibly clean.

Creating Your First ViewModel

Let’s start with the foundation – our MainViewModel. Create a new folder called ViewModels and add this class:

using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using System.Collections.ObjectModel;

namespace TodoApp.ViewModels;

public partial class MainViewModel : ObservableObject
{
    [ObservableProperty]
    private string newTaskText = string.Empty;
    
    public ObservableCollection<TodoItemViewModel> Tasks { get; } = new();
    
    [RelayCommand]
    private void AddTask()
    {
        if (!string.IsNullOrWhiteSpace(NewTaskText))
        {
            Tasks.Add(new TodoItemViewModel { Text = NewTaskText });
            NewTaskText = string.Empty;
        }
    }
    
    [RelayCommand]
    private void RemoveTask(TodoItemViewModel task)
    {
        Tasks.Remove(task);
    }
}

Breaking Down the ViewModel Magic

The partial keyword: Community Toolkit MVVM uses source generators, so your class needs to be partial to allow the generated code to merge seamlessly.

[ObservableProperty]: This attribute generates a full property with INotifyPropertyChanged implementation. No more tedious boilerplate! It creates a public property called NewTaskText from your private field newTaskText.

[RelayCommand]: Transforms your methods into ICommand implementations automatically. The method AddTask() becomes an AddTaskCommand property that your View can bind to.

Creating the TodoItem ViewModel

Now let’s create the individual todo item ViewModel:

using CommunityToolkit.Mvvm.ComponentModel;

namespace TodoApp.ViewModels;

public partial class TodoItemViewModel : ObservableObject
{
    [ObservableProperty]
    private string text = string.Empty;
    
    [ObservableProperty]
    private bool isCompleted;
    
    [ObservableProperty]
    private string completionStatus = "Pending";
    
    partial void OnIsCompletedChanged(bool value)
    {
        CompletionStatus = value ? "Completed" : "Pending";
    }
}

The partial void OnIsCompletedChanged: This is a generated partial method hook that gets called whenever IsCompleted changes. Perfect for updating dependent properties!

Designing the View with Proper Data Binding

Now let’s create the UI that connects to our ViewModels. Update your MainWindow.axaml:

<Window xmlns="https://github.com/avaloniaui"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:vm="clr-namespace:TodoApp.ViewModels"
        x:Class="TodoApp.MainWindow"
        Title="MVVM Todo App"
        Width="500" Height="600">

    <Window.DataContext>
        <vm:MainViewModel />
    </Window.DataContext>

    <Grid RowDefinitions="Auto,*" Margin="20">
        
        <!-- Add Task Section -->
        <StackPanel Grid.Row="0" Orientation="Horizontal" Spacing="10" Margin="0,0,0,20">
            <TextBox Text="{Binding NewTaskText}" 
                     Watermark="Enter a new task..."
                     Width="300" />
            <Button Content="Add Task" 
                    Command="{Binding AddTaskCommand}" />
        </StackPanel>
        
        <!-- Tasks List -->
        <ScrollViewer Grid.Row="1">
            <ItemsControl ItemsSource="{Binding Tasks}">
                <ItemsControl.ItemTemplate>
                    <DataTemplate DataType="vm:TodoItemViewModel">
                        <Border BorderBrush="LightGray" 
                                BorderThickness="1" 
                                Padding="10" 
                                Margin="0,5"
                                CornerRadius="5">
                            <Grid ColumnDefinitions="*,Auto,Auto">
                                
                                <TextBlock Grid.Column="0"
                                          Text="{Binding Text}"
                                          VerticalAlignment="Center"
                                          TextDecorations="{Binding IsCompleted, Converter={StaticResource BoolToStrikethroughConverter}}" />
                                
                                <TextBlock Grid.Column="1"
                                          Text="{Binding CompletionStatus}"
                                          VerticalAlignment="Center"
                                          Margin="10,0"
                                          Foreground="{Binding IsCompleted, Converter={StaticResource BoolToColorConverter}}" />
                                
                                <StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="5">
                                    <CheckBox IsChecked="{Binding IsCompleted}" />
                                    <Button Content="Delete"
                                            Command="{Binding $parent[Window].DataContext.RemoveTaskCommand}"
                                            CommandParameter="{Binding}" />
                                </StackPanel>
                            </Grid>
                        </Border>
                    </DataTemplate>
                </ItemsControl.ItemTemplate>
            </ItemsControl>
        </ScrollViewer>
    </Grid>
</Window>

Adding Value Converters for Better UX

Create a Converters folder and add these converters to enhance the user experience:

using Avalonia.Data.Converters;
using Avalonia.Media;
using System;
using System.Globalization;

namespace TodoApp.Converters;

public class BoolToColorConverter : IValueConverter
{
    public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
    {
        return value is true ? Brushes.Green : Brushes.Orange;
    }

    public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
    {
        throw new NotImplementedException();
    }
}

public class BoolToStrikethroughConverter : IValueConverter
{
    public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
    {
        return value is true ? TextDecorations.Strikethrough : null;
    }

    public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
    {
        throw new NotImplementedException();
    }
}

Don’t forget to register these converters in your App.axaml:

<Application.Resources>
    <converters:BoolToColorConverter x:Key="BoolToColorConverter" />
    <converters:BoolToStrikethroughConverter x:Key="BoolToStrikethroughConverter" />
</Application.Resources>

Understanding the MVVM Separation of Concerns

Let’s break down what makes this architecture so powerful:

The View Layer (XAML)

  • Responsibility: Pure presentation and user interaction
  • Contains: UI elements, data binding expressions, styling
  • Doesn’t contain: Business logic, data manipulation, or complex calculations

The ViewModel Layer

  • Responsibility: Presentation logic and state management
  • Contains: Properties for data binding, commands for user actions, validation logic
  • Doesn’t contain: UI-specific code or direct data access

The Model Layer (implied in our ViewModels)

  • Responsibility: Business logic and data representation
  • Contains: Domain objects, business rules, data validation
  • Doesn’t contain: UI concerns or presentation logic

Command Pattern Deep Dive

Commands are the backbone of MVVM interaction. Here’s why they’re superior to event handlers:

Benefits of Commands

  1. Testability: Commands can be easily unit tested without UI
  2. Reusability: Same command can be triggered from different UI elements
  3. Enable/Disable Logic: Commands have built-in CanExecute functionality
  4. Parameter Passing: Clean way to pass data from View to ViewModel

Adding CanExecute Logic

Let’s enhance our AddTask command with validation:

[RelayCommand(CanExecute = nameof(CanAddTask))]
private void AddTask()
{
    if (!string.IsNullOrWhiteSpace(NewTaskText))
    {
        Tasks.Add(new TodoItemViewModel { Text = NewTaskText });
        NewTaskText = string.Empty;
    }
}

private bool CanAddTask() => !string.IsNullOrWhiteSpace(NewTaskText);

partial void OnNewTaskTextChanged(string value)
{
    AddTaskCommand.NotifyCanExecuteChanged();
}

Now the Add Task button automatically enables/disables based on whether there’s text in the input field!

Testing Your MVVM Implementation

One of MVVM’s greatest strengths is testability. Here’s how you can unit test your ViewModels:

using Xunit;
using TodoApp.ViewModels;

namespace TodoApp.Tests;

public class MainViewModelTests
{
    [Fact]
    public void AddTask_WithValidText_AddsTaskToCollection()
    {
        // Arrange
        var viewModel = new MainViewModel();
        viewModel.NewTaskText = "Test task";
        
        // Act
        viewModel.AddTaskCommand.Execute(null);
        
        // Assert
        Assert.Single(viewModel.Tasks);
        Assert.Equal("Test task", viewModel.Tasks[0].Text);
        Assert.Empty(viewModel.NewTaskText);
    }
    
    [Fact]
    public void AddTask_WithEmptyText_DoesNotAddTask()
    {
        // Arrange
        var viewModel = new MainViewModel();
        viewModel.NewTaskText = "";
        
        // Act
        viewModel.AddTaskCommand.Execute(null);
        
        // Assert
        Assert.Empty(viewModel.Tasks);
    }
}

Running Your Application

Hit F5 in Rider or run from the terminal:

dotnet run

You should see a clean todo application where you can:

  • Type tasks and add them
  • Check tasks as complete (they’ll turn green and get crossed out)
  • Delete tasks you no longer need
  • See the Add button disabled when the input is empty

Common MVVM Pitfalls to Avoid

1. ViewModels Knowing About Views

// DON'T DO THIS
public class BadViewModel 
{
    public void DoSomething(MainWindow window)
    {
        window.SomeTextBox.Text = "Bad!";
    }
}

2. Views Containing Business Logic

// DON'T DO THIS in code-behind
private void Button_Click(object sender, RoutedEventArgs e)
{
    // Complex business logic here...
    var result = CalculateComplexStuff();
    UpdateDatabase(result);
}

3. Forgetting to Implement INotifyPropertyChanged

Without proper change notification, your UI won’t update when data changes. Always use [ObservableProperty] or implement INotifyPropertyChanged manually.

Performance Tips for MVVM Applications

Use ObservableCollection Wisely

For large datasets, consider virtualization or implement custom collection change handling:

// For better performance with large lists
public ObservableCollection<TodoItemViewModel> Tasks { get; } = new();

// Consider this approach for very large datasets
private void AddMultipleTasks(IEnumerable<TodoItemViewModel> newTasks)
{
    Tasks.Clear();
    foreach (var task in newTasks)
    {
        Tasks.Add(task);
    }
}

Minimize Property Change Notifications

Only notify when values actually change:

[ObservableProperty]
private string text = string.Empty;

partial void OnTextChanged(string value)
{
    // Only execute expensive operations if needed
    if (value?.Length > 100)
    {
        PerformExpensiveValidation(value);
    }
}

You’ve just built a complete MVVM application that demonstrates all the core concepts: ViewModels with proper data binding, commands for user interactions, and clean separation of concerns. The Community Toolkit MVVM has eliminated most of the boilerplate code, letting you focus on building great features rather than fighting with infrastructure.

This architectural pattern will serve you well as your applications grow in complexity. The separation of concerns makes your code easier to test, maintain, and extend. Your future self (and your team) will thank you for writing clean, well-structured MVVM code.

Mohammed Chami
Mohammed Chami
Articles: 44

Leave a Reply

Your email address will not be published. Required fields are marked *