Skip to content

Repository files navigation

BlazorMvvm

https://github.com/github/docs/actions/workflows/main.yml GitHub NuGet version NuGet downloads

A lightweight, AOT-compatible MVVM toolkit for Blazor with powerful source generation capabilities.

NuGet Package | Live Demo


Table of Contents


Package requirements

This package uses Microsoft.AspNetCore.Components.Web package. The required version depends on your target .NET version:

.NET Version Required Package
.NET 10.0 Microsoft.AspNetCore.Components.Web ≥ 10.0.0
.NET 9.0 Microsoft.AspNetCore.Components.Web ≥ 9.0.0
.NET 8.0 Microsoft.AspNetCore.Components.Web ≥ 8.0.0
.NET 7.0 Microsoft.AspNetCore.Components.Web ≥ 7.0.0
.NET 6.0 Microsoft.AspNetCore.Components.Web ≥ 6.0.0

Quick Start

1. Install the package

dotnet add package gbastecki.BlazorMvvm

2. Add package reference

<PackageReference Include="gbastecki.BlazorMvvm" Version="*" />

3. Register ViewModelFactory (optional, for ViewModel DI)

// Program.cs
builder.Services.UseBlazorMvvmViewModelFactory();

4. Create a ViewModel

[BlazorMvvmViewModel(ViewModelLifetime.Transient)]
public partial class CounterViewModel : BlazorViewModel
{
    [BlazorObservableProperty]
    private int _count;

    [BlazorCommand]
    private void Increment() => Count++;
}

5. Create a Component

@inherits BlazorMvvmComponentBase<CounterViewModel>
@page "/counter"

<h1>Count: @BaseViewModel.Count</h1>
<button @onclick="BaseViewModel.IncrementCommand.Execute">+1</button>

Core Features

Observable Properties

Convert private fields into observable properties with automatic change notification.

Source Generation (Recommended)

public partial class MyViewModel : BlazorViewModel
{
    [BlazorObservableProperty]
    private string _name;
    
    [BlazorObservableProperty]
    private int _counter;
    
    [BlazorObservableProperty(Name = "CustomName")]
    private string _internalField;
}

Generates:

public string Name
{
    get => _name;
    set => Set(ref _name, value);
}

public int Counter
{
    get => _counter;
    set => Set(ref _counter, value);
}

public string CustomName
{
    get => _internalField;
    set => Set(ref _internalField, value);
}

Manual Implementation

public class MyViewModel : BlazorViewModel
{
    private int _counter;
    public int Counter
    {
        get => _counter;
        set => Set(ref _counter, value); // Automatically notifies UI
        
        // or use manual setter:
        //set
        //{
        //    if (_counter == value) return;
        //    _counter = value;
        //    OnPropertyChanged(nameof(Counter));
        //}
    }
}

Awaiting Property Changes

BlazorViewModel exposes an asynchronous OnPropertyChangedAsync method that awaits until the Blazor UI finishes rendering the updated state:

public async Task UpdateStatusAsync(string newStatus)
{
    Status = newStatus;
    // Await until the UI component actually renders the update
    await OnPropertyChangedAsync(nameof(Status));
}
Controlling Context Capturing (ConfigureAwait)

Just like with asynchronous commands, you can configure synchronization context capturing for OnPropertyChangedAsync via the optional continueOnCapturedContext parameter (defaulting to true):

public async Task UpdateStatusAsync(string newStatus)
{
    Status = newStatus;
    // Disable capturing the UI context when awaiting property change renders
    await OnPropertyChangedAsync(nameof(Status), continueOnCapturedContext: false);
}

Debounced Refreshing

BlazorViewModel provides a built-in DebounceRefresh method to throttle rapid or high-frequency UI updates (e.g. while typing in a search box or receiving high-frequency WebSocket streams).

Calling DebounceRefresh automatically schedules a property change notification after a specified delay. If another debounce request is made for the same property during the delay, the timer resets.

public class SearchViewModel : BlazorViewModel
{
    private string _searchText = string.Empty;

    public string SearchText
    {
        get => _searchText;
        set
        {
            if (_searchText == value) return;
            _searchText = value;

            // Debounce the full view refresh (null) for 400ms to fetch search results
            DebounceRefresh(400, null);
        }
    }
}
Key Features:
  • Implicit Property Names: Calling DebounceRefresh(500) inside a property setter automatically uses [CallerMemberName] to key the debounce timer specifically to that property.
  • Full View Refresh Cancellation: Calling DebounceRefresh(delay, null) (full view refresh) automatically cancels all active property-specific debounce timers, preventing redundant rendering.
  • Disposal Safety: To prevent memory and resource leaks, you can call CancelAllDebounces() when the ViewModel is no longer needed (for example, inside a custom IDisposable.Dispose() implementation).

Commands

Bind UI actions to ViewModel methods with built-in execution state management.

Synchronous Commands

[BlazorCommand]
private void Save() => SaveData();

// Generated: public IBlazorCommand SaveCommand { get; }

Async Commands

[BlazorCommand]
private async Task LoadDataAsync()
{
    await Task.Delay(1000);
}

// Generated: public IBlazorAsyncCommand LoadDataAsyncCommand { get; }

Asynchronous commands implement interfaces that expose both Execute() and ExecuteAsync():

  • Execute(): Invokes the command in a fire-and-forget manner (async void / discarded Task).
  • ExecuteAsync(): Returns a Task that can be awaited. This is highly recommended when invoking commands programmatically (e.g., from component lifecycle hooks, tests, or other ViewModel logic) to support proper exception propagation and flow control:
// Programmatic await usage
await ViewModel.LoadDataAsyncCommand.ExecuteAsync();

Controlling Context Capturing (ConfigureAwait)

Asynchronous commands expose a ContinueOnCapturedContext property (defaulting to true).

  • If set to false, the command will run/resume its execution on a thread pool thread (ConfigureAwait(false)).
  • Since Blazor component UI refreshes in BlazorMvvmComponentBase are dispatched via InvokeAsync, it is completely thread-safe to run commands on background threads; the UI will not crash with threading exceptions.
  • This is especially useful for offloading heavy work to background threads.
Source Generation

Use the named parameter ContinueOnCapturedContext = false in [BlazorCommand]:

[BlazorCommand(ContinueOnCapturedContext = false)]
private async Task LoadDataAsync()
{
    // The source generator configures the generated command with ContinueOnCapturedContext = false
}
Manual Implementation
public MyViewModel()
{
    // Configure command to execute without capturing the UI synchronization context
    LoadDataCommand = new BlazorAsyncCommand(LoadDataAsync) 
    { 
        ContinueOnCapturedContext = false 
    };
}

Commands with Parameters

// Single parameter
[BlazorCommand]
private void UpdateName(string name) => Name = name;
// Generated: public IBlazorRelayCommand<string> UpdateNameCommand { get; }

// Multiple parameters (uses tuple)
[BlazorCommand]
private void Add(int a, int b) => Total = a + b;
// Generated: public IBlazorRelayCommand<(int, int)> AddCommand { get; }
// Usage: AddCommand.Execute((5, 10))

CanExecute Logic

[BlazorCommand(CanExecute = nameof(CanSave))]
private void Save() => SaveData();

private bool CanSave() => !string.IsNullOrEmpty(Name);

Concurrency Control

[BlazorCommand(AllowConcurrentExecutions = false)]
private async Task SubmitAsync()
{
    // Prevents multiple concurrent executions
}

Auto-Refresh on IsExecuting Changed

Automatically trigger UI refresh when IsExecuting state changes

[BlazorCommand(autoRefreshOnIsExecutingChanged: true)]
private async Task LoadDataAsync()
{
    await Task.Delay(5000);
}
<button @onclick="ViewModel.LoadDataAsyncCommand.Execute"
        disabled="@ViewModel.LoadDataAsyncCommand.IsExecuting">
    @if (ViewModel.LoadDataAsyncCommand.IsExecuting)
    {
        <span>Loading...</span>
    }
    else
    {
        <span>Loaded Data</span>
    }
</button>

Custom Callback on IsExecuting Changed

[BlazorObservableProperty]
private bool _isLoading;

[BlazorCommand(OnIsExecutingChangedCallback = nameof(OnLoadingChanged))]
private async Task SaveAsync() => await SaveDataAsync();

private void OnLoadingChanged(bool isExecuting)
{
    IsLoading = isExecuting;
    // Additional logic here
}

Combined: Auto-Refresh + Custom Callback

[BlazorCommand(autoRefreshOnIsExecutingChanged: true, OnIsExecutingChangedCallback = nameof(OnSaving))]
private async Task SaveAsync() => await SaveDataAsync();

private void OnSaving(bool isExecuting)
{
    // Custom logic runs AFTER OnPropertyChanged() is called
    _logger.Log($"Saving state: {isExecuting}");
}

ObservableComponent

Optimize UI updates by isolating which parts of your component re-render.

Full Update Mode

Re-renders when ANY property on the ViewModel changes:

<ObservableComponent ViewModel="MyViewModel">
    <p>Name: @MyViewModel.Name</p>
    <p>Count: @MyViewModel.Count</p>
</ObservableComponent>

Selective Update Mode

Re-renders ONLY when specified properties change:

<!-- Only updates when Counter1 changes -->
<ObservableComponent ViewModel="SharedVM" 
    PropertyNames="[nameof(SharedVM.Counter1)]">
    <p>Counter1: @SharedVM.Counter1</p>
</ObservableComponent>

<!-- Only updates when Counter2 or Counter3 changes -->
<ObservableComponent ViewModel="SharedVM" 
    PropertyNames="[nameof(SharedVM.Counter2), nameof(SharedVM.Counter3)]">
    <p>Counter2: @SharedVM.Counter2</p>
    <p>Counter3: @SharedVM.Counter3</p>
</ObservableComponent>

BlazorMessenger

Cross-ViewModel communication with weak reference support for automatic cleanup.

Messenger Setup

Choose between Dependency Injection or static singleton access:

// Option 1: Dependency Injection (Recommended)
// Program.cs
builder.Services.AddSingleton<IBlazorMessenger, BlazorMessenger>();

// ViewModel - Inject via constructor
public class MyViewModel : BlazorViewModel
{
    private readonly IBlazorMessenger _messenger;
    
    public MyViewModel(IBlazorMessenger messenger)
    {
        _messenger = messenger;
    }
}

// Option 2: Static Singleton
// No registration needed - access directly
BlazorMessenger.Default.Send(new MyMessage());

Define Messages

// Simple value message
public class CounterChangedMessage : ValueChangedMessage<int>
{
    public CounterChangedMessage(int value) : base(value) { }
}

// Request/Response message
public class GetUserRequest : RequestMessage<User> { }

Send Messages

// Via injected messenger
_messenger.Send(new CounterChangedMessage(42));

// Via static default instance
BlazorMessenger.Default.Send(new CounterChangedMessage(42));

Complete Receiver Pattern (Recommended)

Register in constructor, unregister in Dispose to prevent memory leaks:

[BlazorMessenger]
public partial class ReceiverViewModel : BlazorViewModel,
    IBlazorRecipient<CounterChangedMessage>, IDisposable
{
    private readonly IBlazorMessenger _messenger;
    
    [BlazorObservableProperty]
    private int _counter;

    // Register in constructor - starts listening
    public ReceiverViewModel(IBlazorMessenger messenger)
    {
        _messenger = messenger;
        RegisterMessenger(_messenger); // Generated method
    }

    // Unregister in Dispose - stops listening, prevents leaks
    public void Dispose()
    {
        UnregisterMessenger(_messenger); // Generated method
    }

    public void Receive(CounterChangedMessage message)
    {
        Counter = message.Value;
    }
}

Manual Registration (Without Source Generation)

public class ReceiverViewModel : BlazorViewModel,
    IBlazorRecipient<CounterChangedMessage>, IDisposable
{
    private readonly IBlazorMessenger _messenger;

    public ReceiverViewModel(IBlazorMessenger messenger)
    {
        _messenger = messenger;
        _messenger.Register<CounterChangedMessage>(this);
    }

    public void Dispose()
    {
        _messenger.Unregister<CounterChangedMessage>(this);
    }

    public void Receive(CounterChangedMessage message) { }
}

Strong vs Weak Reference Messenger

Feature BlazorMessenger BlazorStrongMessenger
Reference Type Weak Strong
GC Behavior Recipients auto-collected Recipients kept alive
Memory Leaks Forgiving if you forget Dispose Must unregister manually
Use Case Most scenarios (default) Guaranteed delivery
// Use weak messenger (default) - safer for most cases
builder.Services.AddSingleton<IBlazorMessenger, BlazorMessenger>();

// Use strong messenger - guaranteed delivery, explicit lifecycle
builder.Services.AddSingleton<IBlazorMessenger, BlazorStrongMessenger>();

Channel Tokens

Isolate communication using channel tokens (any IEquatable<T> type):

String Channels
// Register on specific channel
messenger.Register<MyMessage, string>(this, "channel-a",
    (r, m) => ((MyReceiver)r).OnMessage(m));

// Send to that channel only
messenger.Send(new MyMessage(), "channel-a");

// Unregister from channel
messenger.Unregister<MyMessage, string>(this, "channel-a");
Enum Channels (via int)
// Note: Enums don't implement IEquatable<T>, so cast to int
public enum NotificationChannel { System = 0, User = 1, Debug = 2 }

// Register on channel
messenger.Register<LogMessage, int>(this, (int)NotificationChannel.Debug,
    (r, m) => ((LogReceiver)r).OnLog(m));

// Send to channel
messenger.Send(new LogMessage("msg"), (int)NotificationChannel.Debug);

Request/Response Pattern

// Register handler
messenger.Register<GetUserRequest>(this, (r, m) =>
{
    m.Reply(new User("John"));
});

// Send and get response
User user = messenger.Send(new GetUserRequest());

ViewModelFactory

Automatic dependency injection for ViewModels with configurable lifetimes.

Setup

// Program.cs
builder.Services.UseBlazorMvvmViewModelFactory();

Register ViewModels

[BlazorMvvmViewModel(ViewModelLifetime.Singleton)]
public partial class AppViewModel : BlazorViewModel { }

[BlazorMvvmViewModel(ViewModelLifetime.Scoped)]
public partial class PageViewModel : BlazorViewModel { }

[BlazorMvvmViewModel(ViewModelLifetime.Transient)]
public partial class DialogViewModel : BlazorViewModel { }

Constructor Injection

[BlazorMvvmViewModel(ViewModelLifetime.Scoped)]
public partial class HomeViewModel : BlazorViewModel
{
    private readonly IApiService _api;
    private readonly ILogger _logger;

    public HomeViewModel(IApiService api, ILogger logger)
    {
        _api = api;
        _logger = logger;
    }
}

Constructor Selection

[BlazorMvvmViewModel(ViewModelLifetime.Scoped)]
public partial class MyViewModel : BlazorViewModel
{
    public MyViewModel() { }

    [BlazorMvvmViewModelFactoryConstructor] // Use this constructor
    public MyViewModel(IService service) { }
}

Component Usage

public partial class Home : BlazorMvvmComponentBase<HomeViewModel>
{
    // ViewModel is automatically resolved and injected via BaseViewModel
    // No need for manual SetDataContext()
    protected override void OnInitialized()
    {
        base.OnInitialized();
        // BaseViewModel is ready to use!
    }
}

Key Highlights

Feature Benefit
Source Generation Zero runtime reflection, AOT-ready
Trimming Safe Works with .NET trimmer
Weak References Automatic memory cleanup in messenger
Selective Updates Fine-grained UI re-rendering
Auto-Refresh No boilerplate for loading states
Multiple Parameters Tuple support for complex commands
Channel Tokens Isolated message channels
Request/Response Synchronous messaging pattern

Command Attribute Quick Reference

[BlazorCommand]                                    // Basic command
[BlazorCommand(CanExecute = nameof(CanExecute))]   // With validation
[BlazorCommand(AllowConcurrentExecutions = true)]  // Allow parallel execution
[BlazorCommand(autoRefreshOnIsExecutingChanged: true)]  // Auto UI refresh
[BlazorCommand(OnIsExecutingChangedCallback = nameof(Callback))]  // Custom callback
[BlazorCommand(autoRefreshOnIsExecutingChanged: true, OnIsExecutingChangedCallback = nameof(Callback))]  // Both combined

License

This project is licensed under the MIT License - see the LICENSE file for details.

Third-Party Libraries

See the NOTICE file for attribution information.

See it in action

BarcodeTool | Demo — A production app built with BlazorMvvm, featuring barcode generation and scanning.

About

A lightweight, AOT-compatible MVVM toolkit for Blazor with powerful source generation capabilities.

Topics

Resources

Stars

Watchers

Forks

Releases

Used by

Contributors

Languages