Skip to content

gbastecki/BlazorMvvm

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));
        //}
    }
}

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; }

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

License

Stars

Watchers

Forks

Contributors