Type-safe state management for Blazor using C# records.
Inspired by Zustand • Built for C# developers
Upgrading from v1.x? See Breaking Changes in v2.0.0 for migration guide.
No actions. No reducers. No dispatchers. Just C# records with pure methods.
// Define state as a record with transformation methods
public record CounterState(int Count)
{
public CounterState Increment() => this with { Count = Count + 1 };
public CounterState Decrement() => this with { Count = Count - 1 };
}
// Use in components
@inherits StoreComponent<CounterState>
<h1>@State.Count</h1>
<button @onclick="@(() => UpdateAsync(s => s.Increment()))">+</button>What you get:
- Zero boilerplate state management
- Immutable by default (C# records +
withexpressions) - Automatic component updates
- Redux DevTools integration
- Full async support with helpers
- Works with Server, WebAssembly, and Auto modes
dotnet add package EasyAppDev.Blazor.Store// 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. State updates automatically propagate to all subscribed components.
- Core Concepts
- Async Helpers
- Optimistic Updates
- Undo/Redo History
- Query System
- Cross-Tab Sync
- Server Sync (SignalR)
- Immer-Style Updates
- Redux-Style Actions
- Plugin System
- Security
- Selectors & Performance
- Persistence & DevTools
- Middleware
- Blazor Render Modes
- API Reference
- Breaking Changes in v2.0.0
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);
}@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 = "";
}
}
}// Standard registration with all utilities
builder.Services.AddStoreWithUtilities(
TodoState.Initial,
(store, sp) => store
.WithDefaults(sp, "Todos") // DevTools + Logging
.WithPersistence(sp, "todos")); // LocalStorage
// Scoped store (Blazor Server per-user isolation)
builder.Services.AddScopedStoreWithUtilities(
new UserSessionState(),
(store, sp) => store.WithDefaults(sp, "Session"));
// Minimal registration
builder.Services.AddStore(new CounterState(0));Five built-in helpers eliminate async boilerplate:
// State
public record UserState(AsyncData<User> CurrentUser)
{
public static UserState Initial => new(AsyncData<User>.NotAsked());
}
// Component
@if (State.CurrentUser.IsLoading) { <Spinner /> }
@if (State.CurrentUser.HasData) { <p>Welcome, @State.CurrentUser.Data.Name</p> }
@if (State.CurrentUser.HasError) { <p class="error">@State.CurrentUser.Error</p> }async Task LoadUser() => await ExecuteAsync(
() => UserService.GetCurrentUserAsync(),
loading: s => s with { CurrentUser = s.CurrentUser.ToLoading() },
success: (s, user) => s with { CurrentUser = AsyncData<User>.Success(user) },
error: (s, ex) => s with { CurrentUser = AsyncData<User>.Failure(ex.Message) }
);// Search input with 300ms debounce
<input @oninput="@(e => UpdateDebounced(
s => s.SetSearchQuery(e.Value?.ToString() ?? ""),
300))" />// Mouse tracking throttled to 100ms intervals
<div @onmousemove="@(e => UpdateThrottled(
s => s.SetPosition(e.ClientX, e.ClientY),
100))">
Track mouse here
</div>// Automatic caching with deduplication
async Task LoadUserDetails(int userId)
{
var user = await LazyLoad(
$"user-{userId}",
() => UserService.GetUserAsync(userId),
cacheFor: TimeSpan.FromMinutes(5));
await UpdateAsync(s => s.SetSelectedUser(user));
}Update UI immediately, rollback on server error:
// Instant UI update with automatic rollback on failure
await store.UpdateOptimistic(
s => s.RemoveItem(itemId), // Optimistic: remove immediately
async s => await api.DeleteItemAsync(itemId), // Server: actual delete
(s, error) => s.RestoreItem(itemId) // Error: rollback
);// Use server response to update state
await store.UpdateOptimistic<AppState, ServerItem>(
s => s.AddPendingItem(item), // Show pending state
async s => await api.CreateItemAsync(item), // Server creates with ID
(s, result) => s.ConfirmItem(result), // Update with server data
(s, error) => s.RemovePendingItem(item) // Remove on failure
);@inherits StoreComponent<TodoState>
@inject ITodoApi Api
async Task DeleteTodo(Guid id)
{
await Store.UpdateOptimistic(
s => s.RemoveTodo(id),
async _ => await Api.DeleteAsync(id),
(s, _) => s.RestoreTodo(id).SetError("Delete failed")
);
}Full history stack for editor-like experiences:
// Program.cs
builder.Services.AddStoreWithHistory(
new EditorState(),
opts => opts
.WithMaxSize(100) // Max entries
.WithMaxMemoryMB(50) // Memory limit
.ExcludeActions("CURSOR_MOVE", "SELECTION") // Don't track these
.GroupActions(TimeSpan.FromMilliseconds(300)), // Group rapid edits
(store, sp) => store.WithDefaults(sp, "Editor")
);
builder.Services.AddStoreHistory<EditorState>();@inherits StoreComponent<EditorState>
@inject IStoreHistory<EditorState> History
<button @onclick="@(() => History.UndoAsync())" disabled="@(!History.CanUndo)">
Undo
</button>
<button @onclick="@(() => History.RedoAsync())" disabled="@(!History.CanRedo)">
Redo
</button>
<span>@History.CurrentIndex / @History.Count</span>
@code {
// Jump to specific point
async Task GoTo(int index) => await History.GoToAsync(index);
}TanStack Query-inspired data fetching with caching:
builder.Services.AddQueryClient();@inject IQueryClient QueryClient
@code {
private IQuery<User> userQuery = null!;
protected override void OnInitialized()
{
userQuery = QueryClient.CreateQuery<User>(
"user-123", // Cache key
async ct => await api.GetUserAsync(123, ct), // Fetch function
opts => opts
.WithStaleTime(TimeSpan.FromMinutes(5)) // Fresh for 5 min
.WithCacheTime(TimeSpan.FromHours(1)) // Cache for 1 hour
.WithRetry(3) // Retry 3 times
);
}
}
@if (userQuery.IsLoading) { <Spinner /> }
@if (userQuery.IsError) { <Error Message="@userQuery.Error" /> }
@if (userQuery.IsSuccess) { <UserCard User="@userQuery.Data" /> }@code {
private IMutation<UpdateUserRequest, User> mutation = null!;
protected override void OnInitialized()
{
mutation = QueryClient.CreateMutation<UpdateUserRequest, User>(
async (req, ct) => await api.UpdateUserAsync(req, ct),
opts => opts.OnSuccess((_, _) =>
QueryClient.InvalidateQueries("user-*")) // Invalidate cache
);
}
async Task Save()
{
await mutation.MutateAsync(new UpdateUserRequest { Name = "John" });
}
}Sync state across browser tabs in real-time:
builder.Services.AddStore(
new CartState(),
(store, sp) => store
.WithDefaults(sp, "Cart")
.WithTabSync(sp, opts => opts
.Channel("shopping-cart")
.EnableMessageSigning() // HMAC security
.WithDebounce(TimeSpan.FromMilliseconds(100))
.ExcludeActions("HOVER", "FOCUS")) // Don't sync these
);Tab 1: User adds item to cart
↓
Store updates → TabSyncMiddleware broadcasts
↓
Tab 2: Receives update → Store syncs → UI updates
↓
Both tabs show same cart!
No additional code needed in components. Sync happens automatically.
Real-time collaboration with presence and cursors:
builder.Services.AddStore(
new DocumentState(),
(store, sp) => store
.WithDefaults(sp, "Document")
.WithServerSync(sp, opts => opts
.HubUrl("/hubs/documents")
.DocumentId(documentId)
.EnablePresence() // Who's online
.EnableCursorTracking() // Live cursors
.ConflictResolution(ConflictResolution.LastWriteWins)
.OnUserJoined(user => Console.WriteLine($"{user} joined"))
.OnCursorUpdated((userId, pos) => RenderCursor(userId, pos)))
);@inject IServerSync<DocumentState> ServerSync
@code {
protected override async Task OnInitializedAsync()
{
// Set your presence
await ServerSync.UpdatePresenceAsync(new PresenceData
{
DisplayName = currentUser.Name,
Color = "#ff0000"
});
}
// Track cursor position
async Task OnMouseMove(MouseEventArgs e)
{
await ServerSync.UpdateCursorAsync(new CursorPosition
{
X = e.ClientX,
Y = e.ClientY
});
}
}| Mode | Behavior |
|---|---|
ClientWins |
Local changes always win |
ServerWins |
Server changes always win |
LastWriteWins |
Most recent timestamp wins |
Custom |
Your custom resolver |
Clean syntax for complex nested updates:
// Verbose nested updates
await store.UpdateAsync(s => s with {
User = s.User with {
Profile = s.User.Profile with {
Address = s.User.Profile.Address with { City = "NYC" }
}
},
Items = s.Items.Add(newItem)
});// Clean, readable updates
await store.ProduceAsync(draft => draft
.Set(s => s.User.Profile.Address.City, "NYC")
.Append(s => s.Items, newItem));await store.ProduceAsync(draft => draft
// Properties
.Set(s => s.Name, "John") // Set value
.Update(s => s.Count, c => c + 1) // Transform
.SetNull<string?>(s => s.Optional) // Set to null
// Numbers
.Increment(s => s.Count) // count++
.Decrement(s => s.Count) // count--
.Increment(s => s.Count, 5) // count += 5
// Booleans
.Toggle(s => s.IsActive) // !isActive
// Strings
.Concat(s => s.Name, " Jr.") // Append
// Lists (ImmutableList)
.Append(s => s.Items, item) // Add to end
.Prepend(s => s.Items, item) // Add to start
.Remove(s => s.Items, item) // Remove item
.SetAt(s => s.Items, 0, item) // Replace at index
.RemoveAt(s => s.Items, 0) // Remove at index
// Dictionaries (ImmutableDictionary)
.DictSet(s => s.Map, "key", value) // Add/update
.DictRemove(s => s.Map, "key") // Remove
);Type-safe action dispatching for Redux-familiar teams:
public record Increment : IAction<CounterState>;
public record IncrementBy(int Amount) : IAction<CounterState>;
public record Reset : IAction<CounterState>;// Simple dispatch
await store.DispatchAsync<CounterState, Increment>(
new Increment(),
(state, _) => state with { Count = state.Count + 1 }
);
// With payload
await store.DispatchAsync(
new IncrementBy(5),
(state, action) => state with { Count = state.Count + action.Amount }
);
// Pattern matching
await store.DispatchAsync(action, (state, a) => a switch
{
Increment => state with { Count = state.Count + 1 },
IncrementBy i => state with { Count = state.Count + i.Amount },
Reset => state with { Count = 0 },
_ => state
});Extensible hooks for cross-cutting concerns:
public class AnalyticsPlugin : StorePluginBase<AppState>
{
private readonly IAnalytics _analytics;
public AnalyticsPlugin(IAnalytics analytics) => _analytics = analytics;
public override Task OnAfterUpdateAsync(AppState prev, AppState next, string action)
{
_analytics.Track(action, new { prev.Count, next.Count });
return Task.CompletedTask;
}
}// Individual plugin
builder.Services.AddStore(
new AppState(),
(store, sp) => store
.WithPlugin<AppState, AnalyticsPlugin>(sp)
.WithPlugin<AppState, ValidationPlugin>(sp)
);
// Auto-discover from assembly
builder.Services.AddStore(
new AppState(),
(store, sp) => store.WithPlugins(typeof(Program).Assembly, sp)
);public class MyPlugin : StorePluginBase<AppState>
{
public override Task OnStoreCreatedAsync() { /* Store initialized */ }
public override Task OnBeforeUpdateAsync(AppState state, string action) { /* Pre-update */ }
public override Task OnAfterUpdateAsync(AppState prev, AppState next, string action) { /* Post-update */ }
public override Task OnStoreDisposingAsync() { /* Cleanup */ }
public override IMiddleware<AppState>? GetMiddleware() => null; // Custom middleware
}Prevent passwords/tokens from appearing in DevTools:
public record UserState(
string Name,
[property: SensitiveData] string Password,
[property: SensitiveData] string ApiToken
);
// In DevTools: { Name: "John", Password: "[REDACTED]", ApiToken: "[REDACTED]" }public class CartValidator : IStateValidator<CartState>
{
public bool Validate(CartState state, out string? error)
{
if (state.Items.Any(i => i.Quantity < 0))
{
error = "Quantity cannot be negative";
return false;
}
error = null;
return true;
}
}
// Register
builder.Services.AddScoped<IStateValidator<CartState>, CartValidator>();
// Use with server sync
.WithServerSync(sp, opts => opts
.WithValidator(sp.GetService<IStateValidator<CartState>>())
.RejectInvalidState()).WithTabSync(sp, opts => opts
.EnableMessageSigning() // HMAC-SHA256
.WithSigningKey(customKey)) // Optional custom keyStoreComponent<T> re-renders on any state change. For large apps, use selectors:
// Only re-renders when Count changes
@inherits SelectorStoreComponent<AppState, int>
<h1>@State</h1>
@code {
protected override int Selector(AppState state) => state.Count;
}// Single value
protected override int Selector(AppState s) => s.Count;
// Multiple values (tuple)
protected override (string, bool) Selector(AppState s) =>
(s.UserName, s.IsLoading);
// Computed value
protected override int Selector(TodoState s) =>
s.Todos.Count(t => t.Completed);
// Filtered list
protected override ImmutableList<Todo> Selector(TodoState s) =>
s.Todos.Where(t => !t.Completed).ToImmutableList();| Metric | StoreComponent | SelectorStoreComponent |
|---|---|---|
| Re-renders | Every change | Only selected changes |
| Typical reduction | - | 90%+ fewer renders |
builder.Services.AddStore(
new AppState(),
(store, sp) => store
.WithDefaults(sp, "App")
.WithPersistence(sp, "app-state")); // Auto-save & restoreIncluded with WithDefaults(). Features:
- Time-travel debugging
- State inspection
- Action replay
- Import/export
Install: Redux DevTools Extension
#if DEBUG
builder.Services.AddSingleton<IDiagnosticsService, DiagnosticsService>();
builder.Services.AddStore(state, (store, sp) => store.WithDiagnostics(sp));
#endif
// Query in components
@inject IDiagnosticsService Diagnostics
var actions = Diagnostics.GetRecentActions<AppState>(10);
var metrics = Diagnostics.GetPerformanceMetrics<AppState>();public class LoggingMiddleware<TState> : IMiddleware<TState> where TState : notnull
{
public Task OnBeforeUpdateAsync(TState state, string? action)
{
Console.WriteLine($"Before: {action}");
return Task.CompletedTask;
}
public Task OnAfterUpdateAsync(TState prev, TState next, string? action)
{
Console.WriteLine($"After: {action}");
return Task.CompletedTask;
}
}
// Register
.WithMiddleware(new LoggingMiddleware<AppState>()).WithMiddleware(FunctionalMiddleware.Create<AppState>(
onBefore: (state, action) => Console.WriteLine($"Before: {action}"),
onAfter: (prev, next, action) => Console.WriteLine($"After: {action}")
))| Middleware | Purpose |
|---|---|
| DevToolsMiddleware | Redux DevTools |
| PersistenceMiddleware | LocalStorage |
| LoggingMiddleware | Console logging |
| HistoryMiddleware | Undo/redo |
| TabSyncMiddleware | Cross-tab sync |
| ServerSyncMiddleware | SignalR sync |
| PluginMiddleware | Plugin lifecycle |
| DiagnosticsMiddleware | Performance (DEBUG) |
Works with all modes - no code changes needed:
| Feature | Server (Singleton) | Server (Scoped) | WebAssembly | Auto |
|---|---|---|---|---|
| Core Store | ✅ | ✅ | ✅ | ✅ |
| Async Helpers | ✅ | ✅ | ✅ | ✅ |
| DevTools | ✅ Works! | ✅ | ✅ | |
| Persistence | ❌ | ✅ | ✅ |
Use scoped stores for per-user isolation AND DevTools support:
// Scoped = per-user + DevTools work!
builder.Services.AddScopedStoreWithUtilities(
new UserState(),
(store, sp) => store.WithDefaults(sp, "User"));protected TState State { get; }
// Updates
protected Task UpdateAsync(Func<TState, TState> updater, string? action = null);
// Async helpers
protected Task UpdateDebounced(Func<TState, TState> updater, int delayMs);
protected Task UpdateThrottled(Func<TState, TState> updater, int intervalMs);
protected Task ExecuteAsync<T>(Func<Task<T>> action, ...);
protected Task<T> LazyLoad<T>(string key, Func<Task<T>> loader, TimeSpan? cacheFor);// With utilities (recommended)
builder.Services.AddStoreWithUtilities(state, configure);
// Scoped (Blazor Server)
builder.Services.AddScopedStoreWithUtilities(state, configure);
// Basic
builder.Services.AddStore(state, configure);
// Special
builder.Services.AddQueryClient();
builder.Services.AddStoreWithHistory(state, historyOpts, configure);
builder.Services.AddStoreHistory<TState>();store
// Core
.WithDefaults(sp, "Name") // DevTools + Logging
.WithLogging() // Logging only
.WithMiddleware(middleware) // Custom middleware
// Features
.WithPersistence(sp, "key") // LocalStorage
.WithHistory(opts => ...) // Undo/redo
.WithTabSync(sp, opts => ...) // Cross-tab
.WithServerSync(sp, opts => ...) // SignalR
.WithPlugin<TState, TPlugin>(sp) // Plugin
.WithDiagnostics(sp) // DEBUG onlyThis major release introduces powerful new features but includes breaking changes from v1.x:
The IMiddleware<TState> interface now receives both previous and new state in OnAfterUpdateAsync:
// Before (v1.x)
Task OnAfterUpdateAsync(TState newState, string? action);
// After (v2.0)
Task OnAfterUpdateAsync(TState previousState, TState newState, string? action);Migration: Update your middleware implementations to accept the additional previousState parameter.
Optimistic updates now use dedicated extension methods instead of manual patterns:
// Before (v1.x) - Manual rollback pattern
var original = store.GetState();
await store.UpdateAsync(s => s.RemoveItem(id));
try { await api.DeleteAsync(id); }
catch { await store.UpdateAsync(_ => original); throw; }
// After (v2.0) - Built-in support
await store.UpdateOptimistic(
s => s.RemoveItem(id),
async _ => await api.DeleteAsync(id),
(s, error) => s.RestoreItem(id)
);Migration: Replace manual try/catch rollback patterns with UpdateOptimistic().
Plugin hooks now receive both previous and new state:
// Before (v1.x)
public override Task OnAfterUpdateAsync(AppState newState, string action);
// After (v2.0)
public override Task OnAfterUpdateAsync(AppState previousState, AppState newState, string action);Migration: Update plugin OnAfterUpdateAsync overrides to include previousState parameter.
- Query System: TanStack Query-inspired data fetching with
IQueryClient - Immer-Style Updates: Clean syntax with
ProduceAsync()and draft operations - Undo/Redo History: Full history stack with
IStoreHistory<T> - Cross-Tab Sync: Real-time sync with
WithTabSync() - Server Sync: SignalR collaboration with
WithServerSync() - Security:
[SensitiveData]attribute andIStateValidator<T>
- Always use
with:state with { X = 1 }notstate.X = 1 - Use ImmutableList:
Todos.Add(item)returns new list - State methods are pure: No logging, no API calls
- Use UpdateAsync: Synchronous
Update()is obsolete - Register utilities: Call
AddStoreWithUtilities()for async helpers - Blazor Server: Use
AddScopedStorefor DevTools support
MIT © EasyAppDev
GitHub • Issues • Discussions