Introduction
BlazorSignalStore is a modern solution for state management in Blazor applications. After working on several Blazor projects using the Container State pattern proposed by Microsoft, I noticed significant amounts of repetitive code: manual subscriptions in OnInitialized, IDisposable implementations in every subscribed component, and the need to manually trigger state changes. Additionally, any property change would cause entire components to re-render unnecessarily.
This is where BlazorSignalStore shines - it provides granular reactivity that only re-renders the specific parts of the UI that actually changed.
Container State Pattern
Microsoft recommends using a class as a container and injecting it into the dependency injection container as Scoped.
builder.Services.AddScoped<ContainerStateService>();You need a class that describes the state and the actions that can be performed on it:
public record FormContainerState { public PersonalInfo PersonalInfo { get; init; } = new(); public Address Address { get; init; } = new(); public UserPreferencesData Preferences { get; init; } = new(); public bool IsSubmitting { get; init; } = false; public SubmissionResult? SubmissionResult { get; init; } = null;
// Validation logic within the container public bool IsValid() { return PersonalInfo.IsValid() && Address.IsValid() && Preferences.IsValid(); }
public bool CanSubmit() { return IsValid() && !IsSubmitting; }
public List<string> GetValidationErrors() { var errors = new List<string>(); errors.AddRange(PersonalInfo.GetValidationErrors()); errors.AddRange(Address.GetValidationErrors()); errors.AddRange(Preferences.GetValidationErrors()); return errors; }
public double GetCompletionPercentage() { var totalFields = 9; // Total required fields var completedFields = 0;
if (!string.IsNullOrWhiteSpace(PersonalInfo.FirstName)) completedFields++; if (!string.IsNullOrWhiteSpace(PersonalInfo.LastName)) completedFields++; if (!string.IsNullOrWhiteSpace(PersonalInfo.Email)) completedFields++; if (!string.IsNullOrWhiteSpace(Address.Street)) completedFields++; if (!string.IsNullOrWhiteSpace(Address.City)) completedFields++; if (!string.IsNullOrWhiteSpace(Address.PostalCode)) completedFields++; if (!string.IsNullOrWhiteSpace(Address.Country)) completedFields++; if (!string.IsNullOrWhiteSpace(Preferences.Theme)) completedFields++; if (Preferences.EmailNotifications || Preferences.SmsNotifications) completedFields++;
return (double)completedFields / totalFields * 100; } }The state container declares actions and notifies subscribers when changes occur:
public class ContainerStateService { private FormContainerState _formState = new();
public event Action? OnChange;
public FormContainerState FormState => _formState;
// Properties derived from the container state public bool IsFormValid => _formState.IsValid(); public bool CanSubmit => _formState.CanSubmit(); public List<string> ValidationErrors => _formState.GetValidationErrors(); public double FormCompletionPercentage => _formState.GetCompletionPercentage();
// Actions that modify the container state public void UpdatePersonalInfo(string firstName, string lastName, string email) { _formState = _formState with { PersonalInfo = _formState.PersonalInfo with { FirstName = firstName, LastName = lastName, Email = email } }; NotifyStateChanged(); }
public void UpdateAddress(string street, string city, string postalCode, string country) { _formState = _formState with { Address = _formState.Address with { Street = street, City = city, PostalCode = postalCode, Country = country } }; NotifyStateChanged(); }
public void UpdatePreferences(bool emailNotifications, bool smsNotifications, string theme) { _formState = _formState with { Preferences = _formState.Preferences with { EmailNotifications = emailNotifications, SmsNotifications = smsNotifications, Theme = theme } }; NotifyStateChanged(); }
private void NotifyStateChanged() => OnChange?.Invoke(); }To use it, you inject the service/state container and implement IDisposable to cancel subscriptions when the component lifecycle ends:
<!-- ContainerExample.razor -->@page "/container-state"@using BlazorSignalStore.Demo.Store@inject ContainerStateService ContainerStateService@implements IDisposable
<PageTitle>Container State Demo - BlazorSignalStore</PageTitle>
<div class="container-fluid"> <div class="row"> <div class="col-12"> <h1 class="display-4 text-center mb-4">📦 Container State Pattern Demo</h1> <p class="lead text-center mb-5"> This demo shows Microsoft's recommended Container State pattern for Blazor applications. Instead of managing individual state properties, all related state is grouped into a single container. </p> </div> </div>
<!-- Progress Bar --> <div class="row mb-4"> <div class="col-12"> <div class="card shadow-sm"> <div class="card-header bg-primary text-white"> <h5 class="mb-0">📊 Form Progress</h5> </div> <div class="card-body"> <div class="progress mb-2" style="height: 25px;"> <div class="progress-bar progress-bar-striped @(ContainerStateService.FormCompletionPercentage == 100 ? "bg-success" : "")" style="width: @ContainerStateService.FormCompletionPercentage%"> @ContainerStateService.FormCompletionPercentage.ToString("F0")% </div> </div> <small class="text-muted">Complete all fields to enable submission</small> </div> </div> </div> </div>
<!-- Form Example --> <div class="row"> <div class="col-md-6"> <input type="text" class="form-control mb-2" placeholder="First Name" value="@ContainerStateService.FormState.PersonalInfo.FirstName" @onchange="@(e => UpdatePersonalInfo(e.Value?.ToString() ?? "", ContainerStateService.FormState.PersonalInfo.LastName, ContainerStateService.FormState.PersonalInfo.Email))" /> </div> <div class="col-md-6"> <input type="text" class="form-control mb-2" placeholder="Last Name" value="@ContainerStateService.FormState.PersonalInfo.LastName" @onchange="@(e => UpdatePersonalInfo(ContainerStateService.FormState.PersonalInfo.FirstName, e.Value?.ToString() ?? "", ContainerStateService.FormState.PersonalInfo.Email))" /> </div> </div></div>
@code { protected override void OnInitialized() { ContainerStateService.OnChange += StateHasChanged; }
private void UpdatePersonalInfo(string firstName, string lastName, string email) { ContainerStateService.UpdatePersonalInfo(firstName, lastName, email); }
private void UpdateAddress(string street, string city, string postalCode, string country) { ContainerStateService.UpdateAddress(street, city, postalCode, country); }
private void UpdatePreferences(bool emailNotifications, bool smsNotifications, string theme) { ContainerStateService.UpdatePreferences(emailNotifications, smsNotifications, theme); }
public void Dispose() { ContainerStateService.OnChange -= StateHasChanged; }}The Problem with Container State
The problem with this solution is that we always have repetitive code for subscribing and unsubscribing from state changes. The main issue is that when receiving changes in any property, the entire component re-renders. This means if we have a form and modify the first name, the entire component will re-render due to the state change subscription.
This is where the reactivity of Signals shines, as only the specific parts that have been modified will re-render partially.
BlazorSignalStore
This implementation simplifies the development experience by eliminating boilerplate code and optimizing the solution to avoid re-renders when a single state property is modified.
builder.Services.AddSignalStore<SignalFormStore>();Store declaration with properties grouped in Signals and Computed properties:
using BlazorSignalStore.Core;
namespace BlazorSignalStore.Demo.Store{ /// <summary> /// A form store demonstrating granular reactivity with Signals. /// Each property is its own Signal, allowing for precise UI updates. /// </summary> public class SignalFormStore : StoreBase { // Personal Info Signals public Signal<string> FirstName { get; } = new(""); public Signal<string> LastName { get; } = new(""); public Signal<string> Email { get; } = new("");
// Address Signals public Signal<string> Street { get; } = new(""); public Signal<string> City { get; } = new(""); public Signal<string> PostalCode { get; } = new(""); public Signal<string> Country { get; } = new("");
// Preferences Signals public Signal<bool> EmailNotifications { get; } = new(false); public Signal<bool> SmsNotifications { get; } = new(false); public Signal<string> Theme { get; } = new("light");
// Computed properties for validation and progress public Computed<bool> IsPersonalInfoValid { get; } public Computed<bool> IsAddressValid { get; } public Computed<bool> IsFormValid { get; } public Computed<double> FormProgress { get; }
public SignalFormStore() { IsPersonalInfoValid = new Computed<bool>(() => !string.IsNullOrWhiteSpace(FirstName.Value) && !string.IsNullOrWhiteSpace(LastName.Value) && !string.IsNullOrWhiteSpace(Email.Value), FirstName, LastName, Email);
IsAddressValid = new Computed<bool>(() => !string.IsNullOrWhiteSpace(Street.Value) && !string.IsNullOrWhiteSpace(City.Value) && !string.IsNullOrWhiteSpace(PostalCode.Value) && !string.IsNullOrWhiteSpace(Country.Value), Street, City, PostalCode, Country);
IsFormValid = new Computed<bool>(() => IsPersonalInfoValid.Value && IsAddressValid.Value, IsPersonalInfoValid, IsAddressValid);
FormProgress = new Computed<double>(() => { var completedFields = 0; var totalFields = 9;
if (!string.IsNullOrWhiteSpace(FirstName.Value)) completedFields++; if (!string.IsNullOrWhiteSpace(LastName.Value)) completedFields++; if (!string.IsNullOrWhiteSpace(Email.Value)) completedFields++; if (!string.IsNullOrWhiteSpace(Street.Value)) completedFields++; if (!string.IsNullOrWhiteSpace(City.Value)) completedFields++; if (!string.IsNullOrWhiteSpace(PostalCode.Value)) completedFields++; if (!string.IsNullOrWhiteSpace(Country.Value)) completedFields++; if (!string.IsNullOrWhiteSpace(Theme.Value)) completedFields++; if (EmailNotifications.Value || SmsNotifications.Value) completedFields++;
return (double)completedFields / totalFields * 100; }, FirstName, LastName, Email, Street, City, PostalCode, Country, Theme, EmailNotifications, SmsNotifications); } }}Component usage is simplified. You just inject the Store and add the Signals in OnInitialized. The Signals themselves handle the rendering logic and change notification automatically:
<!-- SignalFormExample.razor -->@page "/signal-form"@using BlazorSignalStore.Demo.Store@using BlazorSignalStore.Core@inject SignalFormStore SignalFormStore
<PageTitle>Signal Form Demo - BlazorSignalStore</PageTitle>
<div class="container-fluid"> <div class="row"> <div class="col-12"> <h1 class="display-4 text-center mb-4">⚡ Signal Form Demo</h1> <p class="lead text-center mb-5"> This demo shows BlazorSignalStore's granular reactivity - only the specific UI parts that depend on changed signals will re-render. </p> </div> </div>
<!-- Progress Bar --> <div class="row mb-4"> <div class="col-12"> <div class="card shadow-sm"> <div class="card-header bg-success text-white"> <h5 class="mb-0">📊 Form Progress (Reactive)</h5> </div> <div class="card-body"> <div class="progress mb-2" style="height: 25px;"> <div class="progress-bar progress-bar-striped @(formProgress() == 100 ? "bg-success" : "")" style="width: @formProgress()%"> @formProgress().ToString("F0")% </div> </div> <small class="text-muted">Complete all fields to enable submission</small> </div> </div> </div> </div>
<!-- Form Fields --> <div class="row"> <div class="col-md-6"> <input type="text" class="form-control mb-2" placeholder="First Name" value="@firstName()" @onchange="@(e => SignalFormStore.FirstName.Value = e.Value?.ToString() ?? "")" /> </div> <div class="col-md-6"> <input type="text" class="form-control mb-2" placeholder="Last Name" value="@lastName()" @onchange="@(e => SignalFormStore.LastName.Value = e.Value?.ToString() ?? "")" /> </div> </div></div>
@code { private Func<string>? firstName; private Func<string>? lastName; private Func<string>? email; private Func<double>? formProgress; private Func<bool>? isFormValid;
protected override void OnInitialized() { firstName = this.useSignal(SignalFormStore.FirstName); lastName = this.useSignal(SignalFormStore.LastName); email = this.useSignal(SignalFormStore.Email); formProgress = this.useSignal(SignalFormStore.FormProgress); isFormValid = this.useSignal(SignalFormStore.IsFormValid); }}Performance Comparison
Our demo application includes a side-by-side comparison that demonstrates the performance benefits of BlazorSignalStore:
Container State Pattern:
- Re-renders: When any field changes, the entire component re-renders
- Performance: ~100% re-render rate for form interactions
- Code: Requires manual subscription management and IDisposable implementation
BlazorSignalStore:
- Re-renders: Only the specific UI elements that depend on the changed signal re-render
- Performance: ~50% fewer re-renders compared to Container State
- Code: Automatic subscription management, no boilerplate required
Key Advantages of BlazorSignalStore
🚀 Performance Benefits
- Granular Reactivity: Only affected UI parts re-render
- Reduced CPU Usage: Fewer unnecessary render cycles
- Better User Experience: Smoother interactions in complex forms
🛠️ Developer Experience
- Less Boilerplate: No need for manual subscription management
- No IDisposable: Automatic cleanup handled by the library
- Type Safety: Strong typing with generic Signal<T> and Computed<T>
- Intuitive API: Similar to modern reactive frameworks
📊 Scalability
- Large Forms: Performance benefits increase with form complexity
- Complex State: Easy to manage interdependent state with Computed signals
- Memory Efficiency: Automatic subscription cleanup prevents memory leaks
When to Use Each Pattern
Use Container State When:
- ✅ Simple applications with minimal state management needs
- ✅ Following strict Microsoft patterns is required
- ✅ Team is not familiar with reactive programming concepts
Use BlazorSignalStore When:
- ✅ Building complex forms with many fields
- ✅ Performance optimization is important
- ✅ You want modern reactive state management
- ✅ Reducing boilerplate code is a priority
- ✅ Building large-scale applications
Conclusion
While Microsoft’s Container State pattern provides a solid foundation for state management in Blazor, BlazorSignalStore offers significant advantages in both performance and developer experience:
- ~50% fewer re-renders in typical form scenarios
- Eliminates boilerplate code for subscription management
- Provides granular reactivity similar to modern frontend frameworks
- Maintains type safety and follows .NET conventions
- Scales better for complex applications
BlazorSignalStore brings the best of reactive programming to Blazor while maintaining the familiar C# development experience. It’s particularly valuable for applications with complex forms, real-time data, or performance-critical scenarios.
The library proves that you don’t have to choose between performance and developer experience - you can have both.
Try BlazorSignalStore today and experience the difference that granular reactivity makes in your Blazor applications!
dotnet add package BlazorSignalStoreFor more examples and documentation, visit our GitHub repository.