EasyAppDev.Blazor.Store
Zustand-inspired state management for Blazor (WebAssembly/Server/MAUI). Query system, cross-tab sync, SignalR collaboration, undo/redo, optimistic updates, Redux DevTools. .NET 8/9/10.
Install / Use
/learn @mashrulhaque/EasyAppDev.Blazor.StoreREADME
EasyAppDev.Blazor.Store - State Management for Blazor
A Zustand-inspired state management library for Blazor WebAssembly, Blazor Server, and MAUI Hybrid. Zero boilerplate. Built-in data fetching with caching. Cross-tab sync. Real-time collaboration via SignalR. Undo/redo history. Redux DevTools support. All type-safe with C# records.
<div align="center"> <a href="https://mashrulhaque.github.io/EasyAppDev.Blazor.Store/"> <img src="https://img.shields.io/badge/📚_Full_Documentation-4A90E2?style=for-the-badge" alt="Full documentation for EasyAppDev.Blazor.Store - Blazor state management library" /> </a> <a href="http://blazorstore.easyappdev.com/"> <img src="https://img.shields.io/badge/🚀_Live_Demo-28A745?style=for-the-badge" alt="Live demo of Blazor state management with cross-tab sync and undo-redo" /> </a> </div>// Define state as a C# record
public record CounterState(int Count)
{
public CounterState Increment() => this with { Count = Count + 1 };
}
@inherits StoreComponent<CounterState>
<h1>@State.Count</h1>
<button @onclick="@(() => UpdateAsync(s => s.Increment()))">+</button>
That's it. No actions, no reducers, no dispatchers. State updates propagate to all subscribers automatically.
Upgrading from v1.x? See Breaking Changes in v2.0.0 for migration guide.
Why This Library?
| Problem | Solution |
|---------|----------|
| Boilerplate everywhere (actions, reducers, dispatchers) | State = C# record with methods |
| 10 components fetch same data = 10 API calls | Request deduplication - one fetch, shared result |
| Tab 1 updates cart, Tab 2 shows stale data | Cross-tab sync via BroadcastChannel |
| No undo for user mistakes | Built-in undo/redo with memory limits |
| Loading/error state spaghetti | AsyncData<T> replaces boolean flags |
| Optimistic UI rollback is painful | Built-in rollback on server error |
.NET 8, 9, 10 · Blazor Server, WebAssembly, Auto, MAUI Hybrid
Quick Start
Installation
.NET CLI
dotnet add package EasyAppDev.Blazor.Store
Package Manager Console
Install-Package EasyAppDev.Blazor.Store
PackageReference (add to your .csproj)
<PackageReference Include="EasyAppDev.Blazor.Store" Version="2.0.*" />
Setup
// Program.cs
builder.Services.AddStoreWithUtilities(
new CounterState(0),
(store, sp) => store.WithDefaults(sp, "Counter"));
@page "/counter"
@inherits StoreComponent<CounterState>
<h1>@State.Count</h1>
<button @onclick="@(() => UpdateAsync(s => s.Increment()))">+</button>
<button @onclick="@(() => UpdateAsync(s => s.Decrement()))">-</button>
That's it. All components subscribed to CounterState update automatically.
Table of Contents
Getting Started
Data Fetching
Sync & Collaboration
History & Advanced
Reference
- Plugins | Middleware | Security | Render Modes | API | v2.0 Changes | Comparison | FAQ
Core Concepts - Immutable State with C# Records
State = Immutable Record
public record TodoState(ImmutableList<Todo> Todos)
{
public static TodoState Initial => new(ImmutableList<Todo>.Empty);
// Pure transformation methods - no side effects
public TodoState AddTodo(string text) =>
this with { Todos = Todos.Add(new Todo(Guid.NewGuid(), text, false)) };
public TodoState ToggleTodo(Guid id) =>
this with {
Todos = Todos.Select(t =>
t.Id == id ? t with { Completed = !t.Completed } : t
).ToImmutableList()
};
public TodoState RemoveTodo(Guid id) =>
this with { Todos = Todos.RemoveAll(t => t.Id == id) };
// Computed properties
public int CompletedCount => Todos.Count(t => t.Completed);
}
Component = StoreComponent<T>
@page "/todos"
@inherits StoreComponent<TodoState>
<input @bind="newTodo" @onkeyup="HandleKeyUp" />
@foreach (var todo in State.Todos)
{
<div>
<input type="checkbox" checked="@todo.Completed"
@onchange="@(() => UpdateAsync(s => s.ToggleTodo(todo.Id)))" />
@todo.Text
<button @onclick="@(() => UpdateAsync(s => s.RemoveTodo(todo.Id)))">X</button>
</div>
}
<p>Completed: @State.CompletedCount / @State.Todos.Count</p>
@code {
string newTodo = "";
async Task HandleKeyUp(KeyboardEventArgs e)
{
if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(newTodo))
{
await UpdateAsync(s => s.AddTodo(newTodo));
newTodo = "";
}
}
}
Interface Segregation
The IStore<T> interface composes three focused interfaces:
// Read-only state access
public interface IStateReader<TState> where TState : notnull
{
TState GetState();
}
// State update operations
public interface IStateWriter<TState> where TState : notnull
{
Task UpdateAsync(Func<TState, TState> updater, string? action = null);
Task UpdateAsync(Func<TState, Task<TState>> asyncUpdater, string? action = null);
}
// Subscription management
public interface IStateObservable<TState> where TState : notnull
{
IDisposable Subscribe(Action<TState> callback);
IDisposable Subscribe<TSelected>(Func<TState, TSelected> selector, Action<TSelected> callback);
}
// Full store interface
public interface IStore<TState> :
IStateReader<TState>,
IStateWriter<TState>,
IStateObservable<TState>,
IDisposable
where TState : notnull
{
}
Registration Options
AddSecureStore (Recommended)
Automatic security configuration based on environment:
// Simplest secure registration
builder.Services.AddSecureStore(
new AppState(),
"App",
opts =>
{
opts.PersistenceKey = "app-state"; // LocalStorage
opts.EnableTabSync = true; // Cross-tab sync
opts.EnableHistory = true; // Undo/redo
});
Security profiles applied automatically:
Development: DevTools enabled, permissive validationProduction: No DevTools, message signing enabled, validation warningsStrict: All Production features + throws on any security warning
AddStoreWithUtilities
Standard registration with all utilities:
builder.Services.AddStoreWithUtilities(
TodoState.Initial,
(store, sp) => store
.WithDefaults(sp, "Todos") // DevTools + Logging
.WithPersistence(sp, "todos")); // LocalStorage
AddScopedStoreWithUtilities
Scoped store for Blazor Server per-user isolation (required for persistence/DevTools/TabSync):
builder.Services.AddScopedStoreWithUtilities(
new UserSessionState(),
(store, sp) => store
.WithDefaults(sp, "Session")
.WithPersistence(sp, "session-state")); // Works with scoped stores
Note:
WithPersistence(sp, key)requires scoped store registration in Blazor Server. Using it with singleton stores (AddStore) will throw anInvalidOperationExceptionwith guidance to use scoped stores.
AddStore / AddScopedStore
Minimal registration without utilities:
// Singleton
builder.Services.AddStore(new CounterState(0));
// Scoped
builder.Services.AddScopedStore(new CounterState(0));
// With factory
builder.Services.AddStore(
sp => new AppState(sp.GetRequiredService<IConfiguration>()),
(store, sp) => store.WithLogging());
AddStoreWithHistory
Store with undo/redo support:
builder.Services.AddStoreWithHistory(
new EditorState(),
opts => opts
.WithMaxSize(100)
.WithMaxMemoryMB(50)
.ExcludeActions("CURSOR_MOVE", "SELECTION"),
(store, sp) => store.WithDefaults(sp, "Editor")
);
Async Helpers
Built-in utilities for common async patterns. No more writing the same loading/error handling.
ExecuteCachedAsync - Request Deduplication
The problem: 10 components load the same user. Result: 10 API calls, 20 state updates.
The solution: ExecuteCachedAsync deduplicates both the fetch AND the state updates:
// 10 components call this concurrently → 1 API call, 2 state updates
async Task LoadProduct(int productId, CancellationToken ct = default)
{
await ExecuteCachedAsync(
$"product-{productId}",
async () => await ProductService.GetAsync(productId),
loading: s => s with { Product = s.Product.ToLoading() },
success: (s, product) => s with { Product = AsyncData.Success(product) },
error: (s, ex) => s with { Product = AsyncData.Failure(ex.Message) },
cacheFor: TimeSpan.FromMinutes(5),
cancellationToken: ct
);
}
| Scenario | Method |
|----------|--------|
| Multiple components load same data | ExecuteCachedAsync |
