EasyAppDev.Blazor.Store
1.0.7
See the version list below for details.
dotnet add package EasyAppDev.Blazor.Store --version 1.0.7
NuGet\Install-Package EasyAppDev.Blazor.Store -Version 1.0.7
<PackageReference Include="EasyAppDev.Blazor.Store" Version="1.0.7" />
<PackageVersion Include="EasyAppDev.Blazor.Store" Version="1.0.7" />
<PackageReference Include="EasyAppDev.Blazor.Store" />
paket add EasyAppDev.Blazor.Store --version 1.0.7
#r "nuget: EasyAppDev.Blazor.Store, 1.0.7"
#:package EasyAppDev.Blazor.Store@1.0.7
#addin nuget:?package=EasyAppDev.Blazor.Store&version=1.0.7
#tool nuget:?package=EasyAppDev.Blazor.Store&version=1.0.7
EasyAppDev.Blazor.Store
Type-safe state management for Blazor using C# records.
Inspired by Zustand β’ Built for C# developers
π View Full Documentation β
Why This Library?
This library uses C# records with pure transformation methodsβno actions, reducers, or dispatchers. State updates are type-safe with automatic reactivity.
Core Features:
- Zero boilerplate - Write state methods, not actions/reducers
- Immutable by default - C# records +
withexpressions - Automatic reactivity - Components update automatically
- Testable - Pure functions, no mocks needed
- Small footprint - 38 KB gzipped
Async & Performance:
- 5 async helpers - UpdateDebounced, AsyncData<T>, ExecuteAsync, UpdateThrottled, LazyLoad
- Selectors - Granular subscriptions for fewer re-renders
- Persistence - Auto-save/restore with LocalStorage/SessionStorage
- Request deduplication - Automatic caching prevents redundant API calls
Developer Experience:
- Redux DevTools - Time-travel debugging
- Diagnostics - Built-in monitoring (DEBUG only, zero production overhead)
- Middleware - Extensible hooks for logging, validation, custom logic
- SOLID architecture - Interface segregation, dependency injection throughout
- Multiple stores - Separate concerns across focused state domains
If you've used Zustand in React, you'll feel right at home.
Quick Start
Installation
dotnet add package EasyAppDev.Blazor.Store
Requirements: .NET 8.0+ β’ Blazor Server, WebAssembly, or Blazor Web App β’ 38 KB gzipped
Note for Blazor Server/Web App users: See compatibility notes below
1. Define Your State
State is just a C# 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 };
public CounterState Reset() => this with { Count = 0 };
}
2. Register in Program.cs
// One-liner registration with all features
builder.Services.AddStoreWithUtilities(
new CounterState(0),
(store, sp) => store.WithDefaults(sp, "Counter"));
π‘
AddStoreWithUtilities()includes async helpers (debounce, throttle, cache, etc.) π‘WithDefaults()adds DevTools, logging, and JSRuntime integration
3. Use in Components
@page "/counter"
@inherits StoreComponent<CounterState>
<h1>@State.Count</h1>
<button @onclick="@(() => Update(s => s.Increment()))">+</button>
<button @onclick="@(() => Update(s => s.Decrement()))">-</button>
<button @onclick="@(() => Update(s => s.Reset()))">Reset</button>
Inherit from StoreComponent<T> and call Update(). No actions, no reducers, no dispatch.
π― Blazor Render Modes: Server, WebAssembly & Auto
One library, three render modes, zero configuration changes!
The library automatically adapts to your Blazor render mode with intelligent lazy initialization. Use the same code everywhere and let the library handle the differences.
Quick Comparison
| Feature | Server | WebAssembly | Auto (ServerβWASM) |
|---|---|---|---|
| Core State Management | β Full | β Full | β Full |
| Async Helpers | β All work | β All work | β All work |
| Components & Updates | β Perfect | β Perfect | β Perfect |
| Logging Middleware | β Works | β Works | β Works |
| Redux DevTools | β οΈ Gracefully skips | β Works | β Activates after transition |
| LocalStorage Persistence | β Not available | β Works | β Works after transition |
| Code Changes Needed | β None | β None | β None |
Understanding Render Modes
π¦ Blazor Server
- Runs on the server via SignalR
- UI updates sent over WebSocket
IJSRuntimeis scoped (not available at startup)- DevTools: Gracefully skips (no JavaScript at startup)
- Persistence: Not available (use server-side storage instead)
π© Blazor WebAssembly
- Runs entirely in browser
- Downloads .NET runtime to client
IJSRuntimealways available- DevTools: β Full support
- Persistence: β Full support
π¨ Blazor Auto (Server β WebAssembly)
- Phase 1: Starts on server (fast initial load)
- Phase 2: Downloads WASM in background
- Phase 3: Seamlessly transitions to client-side
- DevTools: Automatically activates after transition!
- Persistence: Works after transition
Universal Configuration (Works Everywhere!)
Recommended setup for all modes:
// Program.cs - Same code works in Server, WASM, and Auto!
builder.Services.AddStoreUtilities();
builder.Services.AddStore(
new CounterState(0),
(store, sp) => store.WithDefaults(sp, "Counter"));
What happens in each mode:
| Render Mode | Behavior |
|---|---|
| Server | DevTools silently skips, logging works, app runs perfectly |
| WebAssembly | DevTools active immediately, all features work |
| Auto | DevTools inactive initially, activates automatically after WASM loads |
How Auto Mode Works (Behind the Scenes)
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Phase 1: Server Rendering (0-2 seconds) β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β β’ User loads page β
β β’ Server renders HTML β
β β’ Store initializes with WithDefaults() β
β β’ DevTools tries to resolve IJSRuntime β Not available β
β β’ DevTools marks initialization as failed β Silent skip β
β β’ App works perfectly (core features unaffected) β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Phase 2: WASM Loading (background, 2-5 seconds) β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β β’ .NET WebAssembly runtime downloads β
β β’ User continues interacting with app β
β β’ Store updates work normally β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Phase 3: WASM Active (seamless transition) β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β β’ Next state update occurs β
β β’ DevTools tries to resolve IJSRuntime β Now available! β
β β’ DevTools initializes successfully β
β β’ Redux DevTools becomes active β
β β’ Persistence becomes available β
β β’ No user intervention needed! β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Mode-Specific Configurations
While the universal configuration works everywhere, you can optimize for specific modes:
Blazor Server (Optimized)
// Skip DevTools entirely to avoid initialization attempts
builder.Services.AddStore(
new CounterState(0),
(store, sp) => store.WithLogging()); // Just logging, no DevTools
Blazor WebAssembly (Full Features)
// Enable all features including persistence
builder.Services.AddStoreWithUtilities(
new CounterState(0),
(store, sp) => store
.WithDefaults(sp, "Counter")
.WithPersistence(sp, "counter-state")); // Auto-save to LocalStorage
Blazor Auto (Recommended Default)
// Use WithDefaults - DevTools activates automatically!
builder.Services.AddStoreWithUtilities(
new CounterState(0),
(store, sp) => store.WithDefaults(sp, "Counter"));
Common Scenarios
Scenario 1: Pure Server App (No WASM)
Best approach: Skip DevTools, use logging
builder.Services.AddStore(
new CounterState(0),
(store, sp) => store.WithLogging());
Scenario 2: Progressive Web App (Auto Mode)
Best approach: Use WithDefaults, let it adapt
builder.Services.AddStore(
new CounterState(0),
(store, sp) => store.WithDefaults(sp, "Counter"));
Scenario 3: SPA with Full Client Features
Best approach: Enable all features
builder.Services.AddStoreWithUtilities(
new CounterState(0),
(store, sp) => store
.WithDefaults(sp, "Counter")
.WithPersistence(sp, "app-state"));
Persistence in Server Mode
Since LocalStorage isn't available in pure Server mode, here are alternatives:
Option 1: Server-side storage
// Use database, session state, or distributed cache
public record UserPreferences(string Theme, string Language)
{
public async Task<UserPreferences> SaveToDatabase(IDbContext db)
{
await db.SaveAsync(this);
return this;
}
}
Option 2: Switch to Auto mode
// In Program.cs, add WASM support
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents()
.AddInteractiveWebAssemblyComponents(); // Enable Auto mode
// Then use @rendermode InteractiveAuto in components
Troubleshooting by Render Mode
Server Mode Issues:
- β Store updates work? β Core functionality is fine
- β οΈ DevTools not appearing? β Expected behavior, use logging instead
- β Getting IJSRuntime errors? β Remove
.WithDefaults(), use.WithLogging()
WebAssembly Mode Issues:
- β Everything works? β You're all set!
- β οΈ DevTools not appearing? β Check browser console, install Redux DevTools extension
Auto Mode Issues:
- β οΈ DevTools delayed? β Normal, waits for WASM transition
- β Store works immediately? β Core features work from initial server render
- β Getting errors on startup? β Check that WASM components are registered
Feature Detection
The library automatically detects and adapts:
// DevToolsMiddleware internal logic (simplified)
private async Task EnsureInitializedAsync()
{
if (_initialized || _initializationFailed)
return;
try
{
// Try to resolve IJSRuntime
_jsRuntime = _serviceProvider.GetService<IJSRuntime>();
if (_jsRuntime == null)
{
_initializationFailed = true; // Server mode
return;
}
// Initialize DevTools
await _jsRuntime.InvokeAsync(...);
_initialized = true; // Success!
}
catch
{
_initializationFailed = true; // Graceful failure
}
}
Migration Paths
From Server to Auto:
- Add WebAssembly components:
.AddInteractiveWebAssemblyComponents() - Change render mode:
@rendermode InteractiveAuto - No code changes needed in state management!
From WASM to Auto:
- Add Server components:
.AddInteractiveServerComponents() - Change render mode:
@rendermode InteractiveAuto - No code changes needed in state management!
Key Takeaway: Write your state management code once with
WithDefaults(), and it works perfectly across all render modes with automatic adaptation!
Table of Contents
- Quick Start
- Core Concepts
- Real-World Examples
- Async Helpers
- Performance & Optimization
- Persistence & DevTools
- API Reference
- Best Practices
- Troubleshooting
- Documentation
Core Concepts
State = Immutable Record
public record TodoState(ImmutableList<Todo> Todos)
{
public static TodoState Initial => new(ImmutableList<Todo>.Empty);
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()
};
}
Key principles:
- State methods are pure functions (no side effects, no I/O)
- Always use
withexpressions (never mutate) - Use
ImmutableList<T>for collections - Co-locate transformation logic with state data
StoreComponent = Automatic Reactivity
Inherit from StoreComponent<TState> to get:
- Automatic subscription to state changes
- Access to
Stateproperty Update()method for state transformations- Automatic cleanup on disposal
@inherits StoreComponent<TodoState>
<button @onclick="AddTodo">Add</button>
@code {
async Task AddTodo() => await Update(s => s.AddTodo("New task"));
}
Update Flow
Component calls Update(s => s.Method())
β
Store applies transformation
β
New state created (old unchanged)
β
All subscribers notified
β
Components re-render
Real-World Examples
Todo List with Persistence
// State.cs
public record Todo(Guid Id, string Text, bool Completed);
public record TodoState(ImmutableList<Todo> Todos)
{
public static TodoState Initial => new(ImmutableList<Todo>.Empty);
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) };
}
// Program.cs
builder.Services.AddStoreWithUtilities(
TodoState.Initial,
(store, sp) => store
.WithDefaults(sp, "Todos")
.WithPersistence(sp, "todos")); // Auto-save to LocalStorage
// TodoList.razor
@page "/todos"
@inherits StoreComponent<TodoState>
<input @bind="newTodo" @onkeyup="HandleKeyUp" placeholder="What needs to be done?" />
@foreach (var todo in State.Todos)
{
<div>
<input type="checkbox"
checked="@todo.Completed"
@onchange="@(() => Update(s => s.ToggleTodo(todo.Id)))" />
<span class="@(todo.Completed ? "completed" : "")">@todo.Text</span>
<button @onclick="@(() => Update(s => s.RemoveTodo(todo.Id)))">ποΈ</button>
</div>
}
@code {
string newTodo = "";
async Task HandleKeyUp(KeyboardEventArgs e)
{
if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(newTodo))
{
await Update(s => s.AddTodo(newTodo));
newTodo = "";
}
}
}
Authentication with Async Helpers
// State.cs
public record User(string Id, string Name, string Email);
public record AuthState(AsyncData<User> CurrentUser)
{
public static AuthState Initial => new(AsyncData<User>.NotAsked());
public bool IsAuthenticated => CurrentUser.HasData;
}
// Login.razor
@page "/login"
@inherits StoreComponent<AuthState>
@inject IAuthService AuthService
@if (State.CurrentUser.IsLoading)
{
<p>Logging in...</p>
}
else if (State.IsAuthenticated)
{
<p>Welcome, @State.CurrentUser.Data!.Name!</p>
<button @onclick="Logout">Logout</button>
}
else
{
<input @bind="email" placeholder="Email" />
<input @bind="password" type="password" />
<button @onclick="Login">Login</button>
@if (State.CurrentUser.HasError)
{
<p class="error">@State.CurrentUser.Error</p>
}
}
@code {
string email = "", password = "";
async Task Login() =>
await ExecuteAsync(
() => AuthService.LoginAsync(email, password),
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) }
);
async Task Logout() => await Update(s => AuthState.Initial);
}
Shopping Cart
public record Product(string Id, string Name, decimal Price);
public record CartItem(Product Product, int Quantity);
public record CartState(ImmutableList<CartItem> Items)
{
public decimal Total => Items.Sum(i => i.Product.Price * i.Quantity);
public int ItemCount => Items.Sum(i => i.Quantity);
public CartState AddItem(Product product)
{
var existing = Items.FirstOrDefault(i => i.Product.Id == product.Id);
return existing != null
? this with { Items = Items.Replace(existing, existing with { Quantity = existing.Quantity + 1 }) }
: this with { Items = Items.Add(new CartItem(product, 1)) };
}
public CartState UpdateQuantity(string productId, int quantity) =>
quantity <= 0 ? RemoveItem(productId)
: this with {
Items = Items.Select(i =>
i.Product.Id == productId ? i with { Quantity = quantity } : i
).ToImmutableList()
};
public CartState RemoveItem(string productId) =>
this with { Items = Items.RemoveAll(i => i.Product.Id == productId) };
}
Async Helpers
Built-in helpers to reduce async boilerplate:
| Helper | Eliminates | Code Reduction |
|---|---|---|
| UpdateDebounced | Timer management (17 lines β 1 line) | 94% |
| AsyncData<T> | Loading/error state (20+ lines β 1 property) | 95% |
| ExecuteAsync | Try-catch boilerplate (12 lines β 5 lines) | 58% |
| UpdateThrottled | Throttle logic (22 lines β 1 line) | 95% |
| LazyLoad | Cache + deduplication (15+ lines β 2 lines) | 85% |
UpdateDebounced - Debounced Search
// Before: 17 lines of timer management
private Timer? _timer;
void OnInput(ChangeEventArgs e) {
_timer?.Stop();
_timer = new Timer(300);
_timer.Elapsed += async (_, _) => await Update(...);
_timer.Start();
}
public void Dispose() => _timer?.Dispose();
// After: 1 line
<input @oninput="@(e => UpdateDebounced(s => s.SetQuery(e.Value?.ToString() ?? ""), 300))" />
AsyncData<T> - Simple Async State
// Before: 20+ lines for loading/data/error states
public record UserState(
User? User, bool IsLoading, string? Error)
{
public UserState StartLoading() => this with { IsLoading = true };
public UserState Success(User u) => this with { User = u, IsLoading = false };
public UserState Failure(string e) => this with { Error = e, IsLoading = false };
}
// After: 1 property
public record UserState(AsyncData<User> User);
// Component usage
@if (State.User.IsLoading) { <p>Loading...</p> }
@if (State.User.HasData) { <p>@State.User.Data.Name</p> }
@if (State.User.HasError) { <p>@State.User.Error</p> }
ExecuteAsync - Automatic Error Handling
// Before: 12 lines of try-catch
await Update(s => s.StartLoading());
try {
var data = await Service.LoadAsync();
await Update(s => s.Success(data));
} catch (Exception ex) {
await Update(s => s.Failure(ex.Message));
}
// After: 5 lines with automatic error handling
await ExecuteAsync(
() => Service.LoadAsync(),
loading: s => s with { Data = s.Data.ToLoading() },
success: (s, data) => s with { Data = AsyncData.Success(data) },
error: (s, ex) => s with { Data = AsyncData.Failure(ex.Message) }
);
UpdateThrottled - High-Frequency Events
// Throttle mouse move to max once per 100ms
<div @onmousemove="@(e => UpdateThrottled(s => s.SetPosition(e.ClientX, e.ClientY), 100))">
Mouse tracker
</div>
// Throttle scroll events
<div @onscroll="@(e => UpdateThrottled(s => s.UpdateScroll(), 100))">
Content
</div>
LazyLoad - Automatic Caching
// Automatic caching with 5-minute expiration + request deduplication
var user = await LazyLoad(
$"user-{userId}",
() => UserService.GetUserAsync(userId),
cacheFor: TimeSpan.FromMinutes(5));
// Multiple simultaneous calls are deduplicated into a single API request
Complete Example:
@page "/products"
@inherits StoreComponent<ProductState>
<input @oninput="@(e => UpdateDebounced(s => s.SetQuery(e.Value?.ToString() ?? ""), 300))"
placeholder="Search..." />
<button @onclick="Search">Search</button>
@if (State.Products.IsLoading) { <p>Loading...</p> }
@if (State.Products.HasData)
{
@foreach (var product in State.Products.Data)
{
<div @onclick="@(() => LoadDetails(product.Id))">@product.Name</div>
}
}
@code {
async Task Search() =>
await ExecuteAsync(
() => ProductService.SearchAsync(State.Query),
loading: s => s with { Products = s.Products.ToLoading() },
success: (s, data) => s with { Products = AsyncData.Success(data) }
);
async Task LoadDetails(int id)
{
// 4. LazyLoad with caching
var details = await LazyLoad(
$"product-{id}",
() => ProductService.GetDetailsAsync(id),
TimeSpan.FromMinutes(5));
await Update(s => s.AddDetails(id, details));
}
}
Performance & Optimization
The Problem: Unnecessary Re-renders
By default, StoreComponent<TState> re-renders when any part of state changes. For large apps, this can cause performance issues.
The Solution: SelectorStoreComponent
Subscribe to only the data you need:
// β Re-renders on ANY state change
@inherits StoreComponent<AppState>
<h1>@State.Counter</h1>
// β
ONLY re-renders when Counter changes
@inherits SelectorStoreComponent<AppState>
<h1>@State.Counter</h1>
@code {
protected override object SelectState(AppState state) => state.Counter;
}
Performance Impact:
| Metric | Without Selectors | With Selectors |
|---|---|---|
| Re-renders/sec | ~250 | ~10-15 |
| CPU Usage | 40-60% | 5-10% |
| Frame Rate | 20-30 FPS | 60 FPS |
In high-frequency update scenarios, selectors can reduce re-renders by up to 25x.
Selector Patterns
// Single property
protected override object SelectState(AppState s) => s.UserName;
// Multiple properties (tuple)
protected override object SelectState(AppState s) => (s.UserName, s.IsLoading);
// Computed values (record)
protected override object SelectState(TodoState s) => new TodoStats(
Total: s.Todos.Count,
Completed: s.Todos.Count(t => t.Completed)
);
// Filtered collections
protected override object SelectState(TodoState s) =>
s.Todos.Where(t => !t.Completed).ToImmutableList();
Performance Tips
- Batch updates instead of multiple sequential updates
- Split large stores into focused domains
- Debounce frequent updates (search, resize)
- Lazy load heavy data on-demand
- Use selectors for components that don't need all state
- Profile first - measure render counts before optimizing
Persistence & DevTools
Auto-Save to LocalStorage
builder.Services.AddStoreWithUtilities(
new AppState(),
(store, sp) => store
.WithDefaults(sp, "MyApp")
.WithPersistence(sp, "app-state")); // Auto-save + restore
State is automatically saved on updates and restored on app load.
Redux DevTools Integration
builder.Services.AddStoreWithUtilities(
new AppState(),
(store, sp) => store.WithDefaults(sp, "MyApp")); // Includes DevTools
Features:
- π Time-travel debugging
- π State diffs and inspection
- π¬ Action replay
- πΈ Import/export snapshots
Install: Redux DevTools Extension
Diagnostics (DEBUG builds only)
// Program.cs
builder.Services.AddStoreDiagnostics();
builder.Services.AddStore(
new CounterState(0),
(store, sp) => store
.WithDefaults(sp, "Counter")
.WithDiagnosticsIfAvailable(sp));
// DiagnosticsPage.razor
#if DEBUG
<DiagnosticPanel DisplayMode="DiagnosticDisplayMode.Inline" />
#endif
Tracks action history, performance metrics, renders, and subscriptions. Zero overhead in production (compiled out).
API Reference
StoreComponent<TState>
public abstract class StoreComponent<TState> : ComponentBase
{
// State access
protected TState State { get; }
// Core updates
protected Task Update(Func<TState, TState> updater, string? action = null);
protected Task UpdateAsync(Func<TState, Task<TState>> asyncUpdater, string? action = null);
// Async helpers
protected Task UpdateDebounced(Func<TState, TState> updater, int delayMs, string? action = null);
protected Task UpdateThrottled(Func<TState, TState> updater, int intervalMs, string? action = null);
protected Task ExecuteAsync<T>(
Func<Task<T>> action,
Func<TState, TState> loading,
Func<TState, T, TState> success,
Func<TState, Exception, TState>? error = null);
protected Task<T> LazyLoad<T>(string key, Func<Task<T>> loader, TimeSpan? cacheFor = null);
}
SelectorStoreComponent<TState>
public abstract class SelectorStoreComponent<TState> : ComponentBase
{
protected TState State { get; }
protected object? Selected { get; }
// Required: Override to select specific state slice
protected abstract object SelectState(TState state);
// Update methods (no async helpers)
protected Task Update(Func<TState, TState> updater, string? action = null);
}
Registration Methods
// Recommended: All-in-one
builder.Services.AddStoreWithUtilities(
new MyState(),
(store, sp) => store.WithDefaults(sp, "MyStore"));
// Manual registration
builder.Services.AddStoreUtilities(); // Required for async helpers
builder.Services.AddAsyncActionExecutor<MyState>(); // Required for ExecuteAsync
builder.Services.AddStore(new MyState(), (store, sp) => store.WithDefaults(sp, "MyStore"));
// Scoped stores (Blazor Server)
builder.Services.AddScopedStoreWithUtilities(new SessionState(), ...);
// Transient stores
builder.Services.AddTransientStoreWithUtilities(() => new TempState(), ...);
StoreBuilder Configuration
builder.Services.AddStoreWithUtilities(
new MyState(),
(store, sp) => store
.WithDefaults(sp, "StoreName") // DevTools + Logging + JSRuntime
.WithPersistence(sp, "storage-key") // LocalStorage auto-save
.WithDiagnosticsIfAvailable(sp) // Diagnostics (DEBUG only)
.WithMiddleware(customMiddleware)); // Custom middleware instance
Best Practices
β Do
// β
Use records for state
public record AppState(int Count, string Name);
// β
Use 'with' expressions
public AppState Increment() => this with { Count = Count + 1 };
// β
Use ImmutableList/ImmutableDictionary
public record State(ImmutableList<Item> Items);
// β
Keep state methods pure (no I/O, no logging)
public State AddItem(Item item) => this with { Items = Items.Add(item) };
// β
Batch updates when possible
await Update(s => s.SetLoading(true).ClearErrors().ResetForm());
β Don't
// β Don't mutate state
public void Increment() { Count++; } // Wrong!
// β Don't use mutable collections
public record State(List<Item> Items); // Wrong!
// β Don't add side effects to state methods
public State AddItem(Item item)
{
_logger.Log("Adding"); // Wrong! Side effect!
return this with { Items = Items.Add(item) };
}
// β
Do side effects in components instead
@code {
async Task AddItem(Item item)
{
Logger.LogInformation("Adding item");
await Update(s => s.AddItem(item));
}
}
Troubleshooting
"Cannot resolve scoped service 'IJSRuntime' from root provider"?
β UPDATED: This error no longer occurs! The library now uses lazy IJSRuntime resolution.
If you're seeing this error, you're using an old configuration pattern:
// β Old pattern (caused errors)
var jsRuntime = serviceProvider.GetRequiredService<IJSRuntime>();
builder.Services.AddStore(new State(), store => store.WithDevTools(jsRuntime, "Store"));
// β
New pattern (works everywhere!)
builder.Services.AddStore(
new State(),
(store, sp) => store.WithDefaults(sp, "Store")); // Lazy resolution!
The new WithDefaults(sp, ...) method resolves IJSRuntime lazily, so it works in all render modes (Server, WASM, Auto).
See the Blazor Render Modes section for details.
Component not updating?
β
Inherit from StoreComponent<TState>
State not changing?
β
Use with expressions: state with { Count = 5 }
Collections not updating?
β
Use ImmutableList and its methods
Async operations failing?
β
Use UpdateAsync not Update for async state methods
ExecuteAsync not available?
β
Register services: builder.Services.AddStoreWithUtilities(...)
UpdateDebounced/Throttle/LazyLoad not available?
β
Register utilities: builder.Services.AddStoreUtilities() (or use AddStoreWithUtilities)
Advanced Patterns
Multiple Stores
// Register
builder.Services.AddStore(new AuthState());
builder.Services.AddStore(new CartState());
// Use
@inherits StoreComponent<AuthState>
@inject IStore<CartState> CartStore
<p>User: @State.CurrentUser?.Name</p>
<p>Cart: @CartStore.GetState().ItemCount items</p>
Derived State
public record TodoState(ImmutableList<Todo> Todos)
{
public int CompletedCount => Todos.Count(t => t.Completed);
public double CompletionRate => Todos.Count > 0
? (double)CompletedCount / Todos.Count * 100 : 0;
}
Optimistic Updates
async Task DeleteOptimistically(Guid id)
{
var original = State;
await Update(s => s.RemoveTodo(id)); // Optimistic
try {
await _api.DeleteAsync(id);
} catch {
await Update(_ => original); // Rollback
}
}
Middleware
public class LoggingMiddleware<TState> : IMiddleware<TState>
{
public Task OnBeforeUpdateAsync(TState state, string? action)
{
_logger.LogInformation("Before: {Action}", action);
return Task.CompletedTask;
}
public Task OnAfterUpdateAsync(TState prev, TState next, string? action)
{
_logger.LogInformation("After: {Action}", action);
return Task.CompletedTask;
}
}
// Register
builder.Services.AddStore(
new AppState(),
(store, sp) => store.WithMiddleware(new LoggingMiddleware<AppState>(logger)));
Documentation
π Online Documentation
π Visit the full documentation site β
Interactive guides, examples, and API reference with search and navigation.
Core Guides
- π Architecture Guide - Design patterns and decisions
- π API Design - API philosophy
- π Coding Standards - Best practices and anti-patterns
- π Testing Strategy - How to test your state
Async Helpers
- π Async Helpers Orchestrator - Complete guide
- ποΈ Architecture - Async Helpers - Design decisions
What's New in v1.0.0
Async Helpers - Reduce async boilerplate with 5 helper methods
UpdateDebounced,AsyncData<T>,ExecuteAsync,UpdateThrottled,LazyLoad- 98% test pass rate (299/305 tests)
- Production-ready with live demos
Diagnostics & Monitoring (DEBUG only)
- Built-in diagnostics panel
- Action history with state diffs
- Performance metrics (P95/P99)
- Zero production overhead
Simplified API
WithDefaults(sp, name)- One-liner setupWithPersistence(sp, key)- Auto-save/restoreAddStoreWithUtilities()- All-in-one registration
Contributing
Contributions welcome! Keep it simple and focused.
- Read CODING_STANDARDS.md
- Run tests:
dotnet test - Add tests for new features
- Keep API surface minimal
License
MIT Β© EasyAppDev
<div align="center">
β Star us on GitHub β’ π Report Issues β’ π¬ Discussions
</div>
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 was computed. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net8.0
- Microsoft.AspNetCore.Components.Web (>= 8.0.0)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 8.0.0)
- Microsoft.Extensions.Logging.Abstractions (>= 8.0.0)
- System.Collections.Immutable (>= 8.0.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
v1.0.7 - Latest Release
Type-safe state management for Blazor using C# records. Features include async helpers, Redux DevTools integration, persistence, and granular selectors. Improved Blazor Server/WebAssembly/Auto mode compatibility with lazy initialization.
See CHANGELOG.md for full details.