Mastering State Management in Avalonia UI: Your Complete Guide to MVVM with Community Toolkit

Today, we’re diving deep into state management with the Community Toolkit MVVM – the tool that’ll transform your chaotic code into a well-orchestrated symphony. By the end of this guide, you’ll be confidently sharing data between views like a seasoned developer.

What You’ll Learn Today

  • Creating ViewModels that actually talk to each other
  • Implementing proper state management patterns
  • Building a real-world example that demonstrates everything

Setting Up Your Project Structure

Fire up JetBrains Rider and create a new Avalonia project with MVVM template.

Creating the Foundation Structure

Let’s organize our project properly. Create these folders in your project:

YourProject/
├── Assets/
│   ├── Icons.axaml
├── Models/
├── ViewModels/
│   ├── MainWindowViewModel.cs
│   ├── LoginViewModel.cs
│   ├── DashboardViewModel.cs
│   ├── ViewModelBase.cs
├── Views/
│   ├── MainWindow.axaml
│   ├── LoginView.axaml
│   ├── DashboardView.axaml
├── Services/
│   ├── UserStateService.cs
└── Converters/
    └── BoolToTextConverter.cs

This structure isn’t just for show – it’ll save your sanity as your project grows.

Building Your First Shared State Service

State management starts with a service that multiple ViewModels can access. Think of it as a central information hub where all your views can grab the data they need.

Creating the User State Service

Create Services/UserStateService.cs:

using CommunityToolkit.Mvvm.ComponentModel;
using System.Collections.ObjectModel;
using YourProject.Models;

namespace YourProject.Services;

public partial class UserStateService : ViewModelBase
{
    [ObservableProperty]
    private string _currentUserName = string.Empty;
    
    [ObservableProperty]
    private bool _isUserLoggedIn = false;
    
    [ObservableProperty]
    private ObservableCollection<string> _recentActivities = new();
    
    public void LoginUser(string username)
    {
        CurrentUserName = username;
        IsUserLoggedIn = true;
        AddActivity($"User {username} logged in");
    }
    
    public void LogoutUser()
    {
        var username = CurrentUserName;
        CurrentUserName = string.Empty;
        IsUserLoggedIn = false;
        AddActivity($"User {username} logged out");
    }
    
    private void AddActivity(string activity)
    {
        RecentActivities.Insert(0, $"{DateTime.Now:HH:mm:ss} - {activity}");
        
        // Keep only last 10 activities
        while (RecentActivities.Count > 10)
        {
            RecentActivities.RemoveAt(RecentActivities.Count - 1);
        }
        OnPropertyChanged(nameof(RecentActivities));
    }
    
    public void AddCustomActivity(string activity)
    {
        AddActivity(activity);
    }
}

The [ObservableProperty] attribute is Community Toolkit’s magic wand – it automatically generates property change notifications for you. No more writing those tedious PropertyChanged events manually!

Registering Your Service

In App.axaml.cs, set up dependency injection:

using Microsoft.Extensions.DependencyInjection;
using YourProject.Services;
using YourProject.ViewModels;

public partial class App : Application
{
    public static ServiceProvider? ServiceProvider { get; private set; }
    
    public override void Initialize()
    {
        AvaloniaXamlLoader.Load(this);
        
        var services = new ServiceCollection();
        services.AddScoped<UserStateService>();
        services.AddTransient<MainWindowViewModel>();
        services.AddTransient<LoginViewModel>();
        services.AddTransient<DashboardViewModel>();
        
        ServiceProvider = services.BuildServiceProvider();
    }
    public override void OnFrameworkInitializationCompleted()
    {
        if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
        {
            BindingPlugins.DataValidators.RemoveAt(0);
            DisableAvaloniaDataAnnotationValidation();
            desktop.MainWindow = new MainWindow
            {
                DataContext = ServiceProvider?.GetService<MainWindowViewModel>(),
            };
        }

        base.OnFrameworkInitializationCompleted();
    }
}

Creating ViewModels That Share State

Now comes the fun part – building ViewModels that can communicate with each other through our shared state service.

The Login ViewModel

Create ViewModels/LoginViewModel.cs:

using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using YourProject.Services;

namespace YourProject.ViewModels;

public partial class LoginViewModel : ViewModelBase
{
    private readonly UserStateService _userStateService;
    
    [ObservableProperty]
    private string _username = string.Empty;
    
    [ObservableProperty]
    private string _password = string.Empty;
    
    [ObservableProperty]
    private bool _isLoggingIn = false;
    
    [ObservableProperty]
    private string _errorMessage = string.Empty;
    
    public LoginViewModel(UserStateService userStateService)
    {
        _userStateService = userStateService;
    }

    public LoginViewModel() : this(null!) { }
    
    [RelayCommand]
    private async Task LoginAsync()
    {
        if (string.IsNullOrWhiteSpace(Username) || string.IsNullOrWhiteSpace(Password))
        {
            ErrorMessage = "Please enter both username and password.";
            return;
        }
            
        IsLoggingIn = true;
        ErrorMessage = string.Empty;

        try
        {
            // simulating an API call
            await Task.Delay(1500);

            if (Password == "demo123")
                _userStateService.LoginUser(Username);
            else
                ErrorMessage = "Invalid credentials.";
        }
        catch (Exception e)
        {
            ErrorMessage = $"Login flailed: {e.Message}";
        }
        finally
        {
            IsLoggingIn = false;
            // Clear password for security
            Password = string.Empty;
        }
    }
    
    [RelayCommand]
    private void ClearForm()
    {
        Username = string.Empty;
        Password = string.Empty;
        ErrorMessage = string.Empty;
    }
}

The Dashboard ViewModel

Create ViewModels/DashboardViewModel.cs:

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

namespace YourProject.ViewModels;

public partial class DashboardViewModel : ViewModelBase
{
    private readonly UserStateService _userStateService;
    [ObservableProperty] private int _activityCount;
    public string CurrentUserName => _userStateService.CurrentUserName;
    public bool IsUserLoggedIn => _userStateService.IsUserLoggedIn;
    public ObservableCollection<string> RecentActivities => _userStateService.RecentActivities;
    
    public DashboardViewModel(UserStateService userStateService)
    {
        _userStateService = userStateService;
        
        ActivityCount = _userStateService.RecentActivities.Count;
        
        // Subscribe to collection changes
        _userStateService.RecentActivities.CollectionChanged += OnCollectionChanged;
        
        // Subscribe to service property changes for other properties
        _userStateService.PropertyChanged += OnServicePropertyChanged;
    }
    public DashboardViewModel(): this(null!) { }
    
    private void OnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
    {
        ActivityCount = _userStateService.RecentActivities.Count;
    }
    
    private void OnServicePropertyChanged(object? sender, PropertyChangedEventArgs e)
    {
        switch (e.PropertyName)
        {
            case nameof(UserStateService.CurrentUserName):
                OnPropertyChanged(nameof(CurrentUserName));
                break;
            case nameof(UserStateService.IsUserLoggedIn):
                OnPropertyChanged(nameof(IsUserLoggedIn));
                break;
        }
    }
    
    [RelayCommand]
    private void Logout()
    {
        _userStateService.LogoutUser();
    }
    
    [RelayCommand]
    private void AddCustomActivity()
    {
        _userStateService.AddCustomActivity("Custom action performed by user");
    }
    
    [RelayCommand]
    private void RefreshData()
    {
        _userStateService.AddCustomActivity("Data refreshed");
    }
    
    [RelayCommand]
    private void ViewProfile()
    {
        _userStateService.AddCustomActivity("Profile viewed");
    }
}

The Main Window: Orchestrating Your Views

Your main window ViewModel acts as the conductor of your application’s symphony:

using CommunityToolkit.Mvvm.ComponentModel;
using YourProject.Services;
using Microsoft.Extensions.DependencyInjection;

namespace YourProject.ViewModels;

public partial class MainWindowViewModel : ViewModelBase
{
    private readonly UserStateService _userStateService;
    
    [ObservableProperty]
    private ViewModelBase _currentView;
    
    public LoginViewModel LoginViewModel { get; }
    public DashboardViewModel DashboardViewModel { get; }
    
    public MainWindowViewModel()
    {
        _userStateService = App.ServiceProvider!.GetService<UserStateService>()!;
        LoginViewModel = App.ServiceProvider!.GetService<LoginViewModel>()!;
        DashboardViewModel = App.ServiceProvider!.GetService<DashboardViewModel>()!;
        
        // Start with login view
        CurrentView = LoginViewModel;
        
        // Listen to login state changes
        _userStateService.PropertyChanged += OnUserStateChanged;
    }
    
    private void OnUserStateChanged(object? sender, PropertyChangedEventArgs e)
    {
        if (e.PropertyName == nameof(UserStateService.IsUserLoggedIn))
        {
            CurrentView = _userStateService.IsUserLoggedIn ? DashboardViewModel : LoginViewModel;
        }
    }
}

Building the Views

Now let’s create the actual UI components that users will interact with.

Login View (Views/LoginView.axaml)

<UserControl x:Class="YourProject.Views.LoginView"
             xmlns="https://github.com/avaloniaui"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
  
  <Design.DataContext>
        <vm:LoginViewModel />
    </Design.DataContext>

    <Grid>
        <Border
            Background="White"
            BoxShadow="0 4 20 0 #20000000"
            CornerRadius="15"
            HorizontalAlignment="Center"
            MaxWidth="400"
            Padding="50"
            VerticalAlignment="Center">

            <StackPanel Spacing="25">
                <!--  Header  -->
                <StackPanel HorizontalAlignment="Center" Spacing="10">
                    <Border
                        Background="#2196f3"
                        CornerRadius="40"
                        Height="80"
                        HorizontalAlignment="Center"
                        Width="80">
                        <PathIcon
                            Data="{StaticResource person_regular}"
                            Foreground="White"
                            Height="40"
                            Width="40" />
                    </Border>
                    <TextBlock
                        FontSize="28"
                        FontWeight="Bold"
                        Foreground="#333"
                        HorizontalAlignment="Center"
                        Text="Welcome Back!" />
                    <TextBlock
                        FontSize="14"
                        Foreground="#666"
                        HorizontalAlignment="Center"
                        Text="Please sign in to continue" />
                </StackPanel>

                <!--  Form Fields  -->
                <StackPanel Spacing="15">
                    <TextBox
                        Classes="modern"
                        Text="{Binding Username}"
                        Watermark="Enter username (try: admin)" />

                    <TextBox
                        Classes="modern"
                        PasswordChar="•"
                        Text="{Binding Password}"
                        Watermark="Enter password (use: demo123)" />

                    <!--  Error Message  -->
                    <TextBlock
                        FontWeight="SemiBold"
                        Foreground="#f44336"
                        IsVisible="{Binding ErrorMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
                        Text="{Binding ErrorMessage}"
                        TextWrapping="Wrap" />
                </StackPanel>

                <!--  Buttons  -->
                <StackPanel Spacing="12">
                    <Button
                        Classes="primary"
                        Command="{Binding LoginCommand}"
                        Content="{Binding IsLoggingIn, Converter={StaticResource BoolToTextConverter}}"
                        HorizontalAlignment="Stretch"
                        IsEnabled="{Binding !IsLoggingIn}" />

                    <Button
                        Classes="secondary"
                        Command="{Binding ClearFormCommand}"
                        Content="Clear Form"
                        HorizontalAlignment="Stretch"
                        HorizontalContentAlignment="Center" />
                </StackPanel>

                <!--  Demo Instructions  -->
                <Border
                    Background="#e3f2fd"
                    BorderBrush="#2196f3"
                    BorderThickness="1"
                    CornerRadius="8"
                    Padding="15">
                    <StackPanel Spacing="5">
                        <TextBlock
                            FontSize="12"
                            FontWeight="SemiBold"
                            Foreground="#1976d2"
                            Text="Demo Instructions:" />
                        <TextBlock
                            FontSize="11"
                            Foreground="#1976d2"
                            Text="• Username: Any name you like" />
                        <TextBlock
                            FontSize="11"
                            Foreground="#1976d2"
                            Text="• Password: demo123" />
                    </StackPanel>
                </Border>
            </StackPanel>
        </Border>
    </Grid>
</UserControl>

Dashboard View (Views/DashboardView.axaml)

<UserControl x:Class="YourProject.Views.DashboardView"
             xmlns="https://github.com/avaloniaui"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
  
  <Design.DataContext>
        <vm:DashboardViewModel />
    </Design.DataContext>

    <Grid Margin="30" RowDefinitions="Auto,*">

        <!--  Header Section  -->
        <Border
            BoxShadow="0 2 15 0 #30000000"
            CornerRadius="10"
            Grid.Row="0"
            Margin="0,0,0,25"
            Padding="25">
            <Border.Background>
                <LinearGradientBrush EndPoint="100%,0%" StartPoint="0%, 0%">
                    <GradientStop Color="#2196f3" Offset="0" />
                    <GradientStop Color="#21cbf3" Offset="1" />
                </LinearGradientBrush>
            </Border.Background>

            <Grid ColumnDefinitions="*,Auto,Auto,Auto">
                <StackPanel Grid.Column="0" VerticalAlignment="Center">
                    <TextBlock
                        FontSize="24"
                        FontWeight="Bold"
                        Foreground="White"
                        Text="{Binding CurrentUserName, StringFormat='Welcome back, {0}!'}" />
                    <TextBlock
                        FontSize="14"
                        Foreground="#E3F2FD"
                        Text="{Binding ActivityCount}" />
                </StackPanel>

                <Button
                    Classes="secondary"
                    Command="{Binding ViewProfileCommand}"
                    Content="Profile"
                    Grid.Column="1"
                    Margin="10,0" />

                <Button
                    Classes="secondary"
                    Command="{Binding RefreshDataCommand}"
                    Content="Refresh"
                    Grid.Column="2"
                    Margin="10,0" />

                <Button
                    Classes="danger"
                    Command="{Binding LogoutCommand}"
                    Content="Logout"
                    Grid.Column="3" />
            </Grid>
        </Border>

        <!--  Main Content  -->
        <Grid ColumnDefinitions="*,300" Grid.Row="1">

            <!--  Activities Panel  -->
            <Border
                Background="White"
                BoxShadow="0 2 10 0 #20000000"
                CornerRadius="10"
                Grid.Column="0"
                Margin="0,0,15,0"
                Padding="25">

                <Grid RowDefinitions="Auto,*,Auto">
                    <Grid
                        ColumnDefinitions="*,Auto"
                        Grid.Row="0"
                        Margin="0,0,0,20">
                        <TextBlock
                            FontSize="20"
                            FontWeight="Bold"
                            Foreground="#333"
                            Grid.Column="0"
                            Text="Activity Timeline" />
                        <TextBlock
                            FontSize="12"
                            Foreground="#666"
                            Grid.Column="1"
                            Text="{Binding ActivityCount}"
                            VerticalAlignment="Bottom" />
                    </Grid>

                    <ScrollViewer Grid.Row="1">
                        <ItemsControl ItemsSource="{Binding RecentActivities}">
                            <ItemsControl.ItemTemplate>
                                <DataTemplate>
                                    <Border
                                        Background="#f8f9fa"
                                        CornerRadius="8"
                                        Margin="0,5"
                                        Padding="15">
                                        <Grid ColumnDefinitions="Auto,*">
                                            <Ellipse
                                                Fill="#2196f3"
                                                Grid.Column="0"
                                                Height="8"
                                                Margin="0,6,15,0"
                                                VerticalAlignment="Top"
                                                Width="8" />
                                            <TextBlock
                                                FontSize="13"
                                                Foreground="#555"
                                                Grid.Column="1"
                                                Text="{Binding}"
                                                TextWrapping="Wrap" />
                                        </Grid>
                                    </Border>
                                </DataTemplate>
                            </ItemsControl.ItemTemplate>
                        </ItemsControl>
                    </ScrollViewer>

                    <Button
                        Classes="primary"
                        Command="{Binding AddCustomActivityCommand}"
                        Content="➕ Add Custom Activity"
                        Grid.Row="2"
                        HorizontalAlignment="Center"
                        Margin="0,20,0,0" />
                </Grid>
            </Border>

            <!--  Stats Panel  -->
            <Border
                Background="White"
                BoxShadow="0 2 10 0 #20000000"
                CornerRadius="10"
                Grid.Column="1"
                Padding="25">

                <StackPanel Spacing="25">
                    <TextBlock
                        FontSize="18"
                        FontWeight="Bold"
                        Foreground="#333"
                        Text="Dashboard Stats" />

                    <!--  User Info Card  -->
                    <Border
                        Background="#e8f5e8"
                        CornerRadius="8"
                        Padding="15">
                        <StackPanel Spacing="8">
                            <TextBlock
                                FontSize="14"
                                FontWeight="SemiBold"
                                Foreground="#2e7d32"
                                Text="User Status" />
                            <TextBlock
                                FontSize="16"
                                Foreground="#1b5e20"
                                Text="{Binding CurrentUserName}" />
                            <TextBlock
                                FontSize="12"
                                Foreground="#388e3c"
                                Text="Online" />
                        </StackPanel>
                    </Border>

                    <!--  Activity Stats  -->
                    <Border
                        Background="#fff3e0"
                        CornerRadius="8"
                        Padding="15">
                        <StackPanel Spacing="8">
                            <TextBlock
                                FontSize="14"
                                FontWeight="SemiBold"
                                Foreground="#ef6c00"
                                Text="Activity Stats" />
                            <TextBlock
                                FontSize="14"
                                Foreground="#e65100"
                                Text="{Binding RecentActivities.Count, StringFormat='Total: {0} activities'}" />
                            <TextBlock
                                FontSize="12"
                                Foreground="#ff8f00"
                                Text="Session active" />
                        </StackPanel>
                    </Border>

                    <!--  Quick Actions  -->
                    <Border
                        Background="#f3e5f5"
                        CornerRadius="8"
                        Padding="15">
                        <StackPanel Spacing="12">
                            <TextBlock
                                FontSize="14"
                                FontWeight="SemiBold"
                                Foreground="#7b1fa2"
                                Text="Quick Actions" />

                            <StackPanel Spacing="8">
                                <Button
                                    Classes="secondary"
                                    Command="{Binding AddCustomActivityCommand}"
                                    CommandParameter="Viewed reports"
                                    Content="View Reports"
                                    HorizontalAlignment="Stretch" />

                                <Button
                                    Classes="secondary"
                                    Command="{Binding AddCustomActivityCommand}"
                                    CommandParameter="Opened settings"
                                    Content="Settings"
                                    HorizontalAlignment="Stretch" />

                                <Button
                                    Classes="secondary"
                                    Command="{Binding AddCustomActivityCommand}"
                                    CommandParameter="Created new task"
                                    Content="New Task"
                                    HorizontalAlignment="Stretch" />
                            </StackPanel>
                        </StackPanel>
                    </Border>
                </StackPanel>
            </Border>
        </Grid>
    </Grid>
</UserControl>

Main Window (Views/MainWindow.axaml)

<Window x:Class="YourProject.Views.MainWindow"
        xmlns="https://github.com/avaloniaui"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="State Management Demo"
        Width="700" Height="550"
        WindowStartupLocation="CenterScreen">
  
  <Design.DataContext>
        <vm:MainWindowViewModel />
    </Design.DataContext>

    <ContentControl Content="{Binding CurrentView}">
        <ContentControl.DataTemplates>
            <DataTemplate DataType="{x:Type vm:LoginViewModel}">
                <views:LoginView />
            </DataTemplate>
            <DataTemplate DataType="{x:Type vm:DashboardViewModel}">
                <views:DashboardView />
            </DataTemplate>
        </ContentControl.DataTemplates>
    </ContentControl>
</Window>

Converter:

namespace YourProjectName.Converters;

public class BoolToTextConverter : IValueConverter
{
    public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
    {
        if (value is bool isLoading)
        {
            return isLoading ? "Logging in..." : "Login";
        }
        return "Login";
    }

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

Icons.axaml file:

<Styles xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
    <Design.PreviewWith>
        <Border Padding="20">
            <!--  Add Controls for Previewer Here  -->
        </Border>
    </Design.PreviewWith>

    <!--  Add Styles Here  -->
    <Styles.Resources>
        <StreamGeometry x:Key="person_regular">M35.7502,28 C38.0276853,28 39.8876578,29.7909151 39.9950978,32.0427546 L40,32.2487 L40,33 C40,36.7555 38.0583,39.5669 35.0798,41.3802 C32.1509,43.1633 28.2139,44 24,44 C19.7861,44 15.8491,43.1633 12.9202,41.3802 C10.0319285,39.6218485 8.11862909,36.9249713 8.00532378,33.3388068 L8,33 L8,32.2489 C8,29.9703471 9.79294995,28.1122272 12.0440313,28.0048972 L12.2499,28 L35.7502,28 Z M35.7502,30.5 L12.2499,30.5 C11.331345,30.5 10.5787597,31.2066575 10.5057976,32.1054618 L10.5,32.2489 L10.5,33 C10.5,35.7444 11.8602,37.8081 14.2202,39.2448 C16.6297,40.7117 20.0677,41.5 24,41.5 C27.9323,41.5 31.3703,40.7117 33.7798,39.2448 C36.0555143,37.8594107 37.4015676,35.8910074 37.4948116,33.2914406 L37.5,33 L37.5,32.2488 C37.5,31.331195 36.7934328,30.5787475 35.8937801,30.5057968 L35.7502,30.5 Z M24,4 C29.5228,4 34,8.47715 34,14 C34,19.5228 29.5228,24 24,24 C18.4772,24 14,19.5228 14,14 C14,8.47715 18.4772,4 24,4 Z M24,6.5 C19.8579,6.5 16.5,9.85786 16.5,14 C16.5,18.1421 19.8579,21.5 24,21.5 C28.1421,21.5 31.5,18.1421 31.5,14 C31.5,9.85786 28.1421,6.5 24,6.5 Z</StreamGeometry>

    </Styles.Resources>
</Styles>

App.axaml:

<Application
    RequestedThemeVariant="Light"
    x:Class="Project12StateManagement.App"
    xmlns="https://github.com/avaloniaui"
    xmlns:conv="using:Project12StateManagement.Converters"
    xmlns:local="using:Project12StateManagement"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
    <!--  "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options.  -->

    <Application.Resources>
        <conv:BoolToTextConverter x:Key="BoolToTextConverter" />
    </Application.Resources>

    <Application.DataTemplates>
        <local:ViewLocator />
    </Application.DataTemplates>

    <Application.Styles>
        <FluentTheme />
        <StyleInclude Source="Assets/Icons.axaml" />

        <Style Selector="TextBox.modern">
            <Setter Property="Padding" Value="12,8" />
            <Setter Property="CornerRadius" Value="5" />
            <Setter Property="BorderThickness" Value="1" />
            <Setter Property="BorderBrush" Value="#ddd" />
            <Setter Property="Background" Value="White" />
        </Style>

        <Style Selector="TextBox.modern:pointerover /template/ Border#PART_BorderElement">
            <Setter Property="BorderBrush" Value="#2196f3" />
            <Setter Property="BorderThickness" Value="2" />
        </Style>

        <Style Selector="TextBox.modern:focus /template/ Border#PART_BorderElement">
            <Setter Property="BorderBrush" Value="#2196f3" />
            <Setter Property="BorderThickness" Value="2" />
        </Style>

        <Style Selector="Button.primary">
            <Setter Property="Background" Value="#2196f3" />
            <Setter Property="Foreground" Value="White" />
            <Setter Property="Padding" Value="20,10" />
            <Setter Property="CornerRadius" Value="5" />
            <Setter Property="FontWeight" Value="SemiBold" />
            <Setter Property="HorizontalContentAlignment" Value="Center" />
        </Style>

        <Style Selector="Button.primary:pointerover /template/ ContentPresenter">
            <Setter Property="Background" Value="#1976d2" />
            <Setter Property="Foreground" Value="White" />
        </Style>

        <Style Selector="Button.secondary">
            <Setter Property="Background" Value="#f5f5f5" />
            <Setter Property="Foreground" Value="#666" />
            <Setter Property="Padding" Value="20,10" />
            <Setter Property="CornerRadius" Value="5" />
            <Setter Property="BorderBrush" Value="#ddd" />
            <Setter Property="BorderThickness" Value="1" />
        </Style>

        <Style Selector="Button.danger">
            <Setter Property="Background" Value="#f44336" />
            <Setter Property="Foreground" Value="White" />
            <Setter Property="Padding" Value="15,8" />
            <Setter Property="CornerRadius" Value="5" />
            <Setter Property="FontWeight" Value="SemiBold" />
        </Style>

        <Style Selector="Button.danger:pointerover /template/ ContentPresenter">
            <Setter Property="Background" Value="#d32f2f" />
        </Style>

    </Application.Styles>
</Application>

Tips for State Management Success

1. Keep Your State Services Focused

Don’t create one massive state service for everything. Instead, create specific services like UserStateService, NavigationService, SettingsService, etc. This makes your code more maintainable and easier to test.

2. The Memory Leak Problem

Here’s what happens with normal event subscriptions:

public class DashboardViewModel : ViewModelBase
{
    private readonly UserStateService _userStateService; // Long-lived service
    
    public DashboardViewModel(UserStateService userStateService)
    {
        _userStateService = userStateService;
        
        // POTENTIAL MEMORY LEAK
        _userStateService.PropertyChanged += OnServicePropertyChanged;
    }
    
    private void OnServicePropertyChanged(object? sender, PropertyChangedEventArgs e)
    {
        // Handle property changes...
    }
}

The Problem:

  • UserStateService is a singleton (lives for the entire app lifetime)
  • DashboardViewModel might be recreated multiple times (when navigating between views)
  • When you subscribe with +=, the service holds a strong reference to your ViewModel
  • Even if you navigate away from the dashboard, the ViewModel can’t be garbage collected because the service still references it
  • Over time, you accumulate multiple “zombie” ViewModels in memory

Visual Example of the Problem:

App Startup:
Service ────> DashboardViewModel₁ (created)

User navigates to Login:
Service ────> DashboardViewModel₁ (should be destroyed, but can't!)
└──> DashboardViewModel₂ (created when returning to dashboard)

User navigates again:
Service ────> DashboardViewModel₁ (zombie - still in memory!)
├──> DashboardViewModel₂ (zombie - still in memory!)
└──> DashboardViewModel₃ (newly created)

we’re eliminating the lifetime mismatch by making the service scoped rather than singleton.

This way, when you navigate away from the dashboard, both the ViewModel AND the service instance can be garbage collected together.

3. Implement Proper Error Handling

Always wrap your state changes in try-catch blocks, especially when dealing with async operations:

[RelayCommand]
private async Task SaveDataAsync()
{
    try
    {
        IsLoading = true;
        await _dataService.SaveAsync(Data);
        // Update state on success
    }
    catch (Exception ex)
    {
        // Handle error and update UI accordingly
        ErrorMessage = "Failed to save data. Please try again.";
    }
    finally
    {
        IsLoading = false;
    }
}

Testing Your State Management

On EndeavourOS, testing is straightforward with the dotnet CLI. Create a simple test to verify your state service works correctly:

[Test]
public void LoginUser_UpdatesStateCorrectly()
{
    // Arrange
    var userStateService = new UserStateService();
    var username = "testuser";
    
    // Act
    userStateService.LoginUser(username);
    
    // Assert
    Assert.IsTrue(userStateService.IsUserLoggedIn);
    Assert.AreEqual(username, userStateService.CurrentUserName);
    Assert.IsTrue(userStateService.RecentActivities.Count > 0);
}

Run your tests with:

dotnet test

Common Pitfalls to Avoid

The Singleton Trap

Just because you can make everything a singleton doesn’t mean you should. Only use singletons for truly global state that needs to persist throughout the application lifecycle.

Direct View Communication

Avoid having views talk directly to each other. Always go through your ViewModels and state services. This keeps your architecture clean and testable.

Forgetting to Dispose

When your ViewModels subscribe to events from services, remember to unsubscribe in the Dispose method to prevent memory leaks.

Wrapping Up

You’ve just built a solid foundation for state management in Avalonia UI! Your application can now:

  • Share data seamlessly between multiple views
  • Maintain consistent state across view transitions
  • Handle user interactions in a predictable way
  • Scale to accommodate more complex state requirements

The Community Toolkit MVVM takes care of all the boilerplate code, letting you focus on building great user experiences.

Happy coding!

Mohammed Chami
Mohammed Chami
Articles: 44

Leave a Reply

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