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