gbastecki.BlazorMvvm
1.5.0
dotnet add package gbastecki.BlazorMvvm --version 1.5.0
NuGet\Install-Package gbastecki.BlazorMvvm -Version 1.5.0
<PackageReference Include="gbastecki.BlazorMvvm" Version="1.5.0" />
<PackageVersion Include="gbastecki.BlazorMvvm" Version="1.5.0" />
<PackageReference Include="gbastecki.BlazorMvvm" />
paket add gbastecki.BlazorMvvm --version 1.5.0
#r "nuget: gbastecki.BlazorMvvm, 1.5.0"
#:package gbastecki.BlazorMvvm@1.5.0
#addin nuget:?package=gbastecki.BlazorMvvm&version=1.5.0
#tool nuget:?package=gbastecki.BlazorMvvm&version=1.5.0
BlazorMvvm
A lightweight, AOT-compatible MVVM toolkit for Blazor with powerful source generation capabilities.
Table of Contents
- Package requirements
- Quick Start
- Core Features
- Key Highlights
- Command Attribute Quick Reference
- License
- Third-Party Libraries
- See it in action
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 customIDisposable.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 aTaskthat 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
BlazorMvvmComponentBaseare dispatched viaInvokeAsync, 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:
<ObservableComponent ViewModel="SharedVM"
PropertyNames="[nameof(SharedVM.Counter1)]">
<p>Counter1: @SharedVM.Counter1</p>
</ObservableComponent>
<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.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net6.0 is compatible. net6.0-android was computed. net6.0-ios was computed. net6.0-maccatalyst was computed. net6.0-macos was computed. net6.0-tvos was computed. net6.0-windows was computed. net7.0 is compatible. net7.0-android was computed. net7.0-ios was computed. net7.0-maccatalyst was computed. net7.0-macos was computed. net7.0-tvos was computed. net7.0-windows was computed. net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 is compatible. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Microsoft.AspNetCore.Components.Web (>= 10.0.0)
-
net6.0
- Microsoft.AspNetCore.Components.Web (>= 6.0.0)
-
net7.0
- Microsoft.AspNetCore.Components.Web (>= 7.0.0)
-
net8.0
- Microsoft.AspNetCore.Components.Web (>= 8.0.0)
-
net9.0
- Microsoft.AspNetCore.Components.Web (>= 9.0.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.