A lightweight, AOT-compatible MVVM toolkit for Blazor with powerful source generation capabilities.
- Package requirements
- Quick Start
- Core Features
- Key Highlights
- Command Attribute Quick Reference
- License
- Third-Party Libraries
- See it in action
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 |
dotnet add package gbastecki.BlazorMvvm<PackageReference Include="gbastecki.BlazorMvvm" Version="*" />// Program.cs
builder.Services.UseBlazorMvvmViewModelFactory();[BlazorMvvmViewModel(ViewModelLifetime.Transient)]
public partial class CounterViewModel : BlazorViewModel
{
[BlazorObservableProperty]
private int _count;
[BlazorCommand]
private void Increment() => Count++;
}@inherits BlazorMvvmComponentBase<CounterViewModel>
@page "/counter"
<h1>Count: @BaseViewModel.Count</h1>
<button @onclick="BaseViewModel.IncrementCommand.Execute">+1</button>Convert private fields into observable properties with automatic change notification.
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);
}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));
//}
}
}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));
}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);
}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);
}
}
}- 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).
Bind UI actions to ViewModel methods with built-in execution state management.
[BlazorCommand]
private void Save() => SaveData();
// Generated: public IBlazorCommand SaveCommand { get; }[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();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.
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
}public MyViewModel()
{
// Configure command to execute without capturing the UI synchronization context
LoadDataCommand = new BlazorAsyncCommand(LoadDataAsync)
{
ContinueOnCapturedContext = false
};
}// 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))[BlazorCommand(CanExecute = nameof(CanSave))]
private void Save() => SaveData();
private bool CanSave() => !string.IsNullOrEmpty(Name);[BlazorCommand(AllowConcurrentExecutions = false)]
private async Task SubmitAsync()
{
// Prevents multiple concurrent executions
}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>[BlazorObservableProperty]
private bool _isLoading;
[BlazorCommand(OnIsExecutingChangedCallback = nameof(OnLoadingChanged))]
private async Task SaveAsync() => await SaveDataAsync();
private void OnLoadingChanged(bool isExecuting)
{
IsLoading = isExecuting;
// Additional logic here
}[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}");
}Optimize UI updates by isolating which parts of your component re-render.
Re-renders when ANY property on the ViewModel changes:
<ObservableComponent ViewModel="MyViewModel">
<p>Name: @MyViewModel.Name</p>
<p>Count: @MyViewModel.Count</p>
</ObservableComponent>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>Cross-ViewModel communication with weak reference support for automatic cleanup.
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());// Simple value message
public class CounterChangedMessage : ValueChangedMessage<int>
{
public CounterChangedMessage(int value) : base(value) { }
}
// Request/Response message
public class GetUserRequest : RequestMessage<User> { }// Via injected messenger
_messenger.Send(new CounterChangedMessage(42));
// Via static default instance
BlazorMessenger.Default.Send(new CounterChangedMessage(42));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;
}
}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) { }
}| 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>();Isolate communication using channel tokens (any IEquatable<T> type):
// 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");// 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);// Register handler
messenger.Register<GetUserRequest>(this, (r, m) =>
{
m.Reply(new User("John"));
});
// Send and get response
User user = messenger.Send(new GetUserRequest());Automatic dependency injection for ViewModels with configurable lifetimes.
// Program.cs
builder.Services.UseBlazorMvvmViewModelFactory();[BlazorMvvmViewModel(ViewModelLifetime.Singleton)]
public partial class AppViewModel : BlazorViewModel { }
[BlazorMvvmViewModel(ViewModelLifetime.Scoped)]
public partial class PageViewModel : BlazorViewModel { }
[BlazorMvvmViewModel(ViewModelLifetime.Transient)]
public partial class DialogViewModel : BlazorViewModel { }[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;
}
}[BlazorMvvmViewModel(ViewModelLifetime.Scoped)]
public partial class MyViewModel : BlazorViewModel
{
public MyViewModel() { }
[BlazorMvvmViewModelFactoryConstructor] // Use this constructor
public MyViewModel(IService service) { }
}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!
}
}| 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 |
[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 combinedThis project is licensed under the MIT License - see the LICENSE file for details.
See the NOTICE file for attribution information.
BarcodeTool | Demo — A production app built with BlazorMvvm, featuring barcode generation and scanning.