Skip to content

Type-safe state management for Blazor using C# records. Includes async helpers, Redux DevTools integration, persistence, and granular selectors. Inspired by Zustand.

License

Notifications You must be signed in to change notification settings

mstortz/EasyAppDev.Blazor.Store

 
 

Repository files navigation

EasyAppDev.Blazor.Store

Type-safe state management for Blazor using C# records.

Inspired by Zustand • Built for C# developers

NuGet License: MIT


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 + with expressions
  • 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 (works in Server mode with scoped stores!)
  • 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="@(() => UpdateAsync(s => s.Increment()))">+</button>
<button @onclick="@(() => UpdateAsync(s => s.Decrement()))">-</button>
<button @onclick="@(() => UpdateAsync(s => s.Reset()))">Reset</button>

Inherit from StoreComponent<T> and call UpdateAsync(). 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 (Singleton) Server (Scoped) WebAssembly Auto (Server→WASM)
Core State Management ✅ Full ✅ Full ✅ Full ✅ Full
Async Helpers ✅ All work ✅ All work ✅ All work ✅ All work
Components & Updates ✅ Perfect ✅ Perfect ✅ Perfect ✅ Perfect
Logging Middleware ✅ Works ✅ Works ✅ Works ✅ Works
Redux DevTools ⚠️ Gracefully skips Works! ✅ Works ✅ Activates after transition
LocalStorage Persistence ❌ Not available ⚠️ Limited ✅ Works ✅ Works after transition
Code Changes Needed ✅ None ✅ None ✅ None ✅ None

Understanding Render Modes

🟦 Blazor Server

  • Runs on the server via SignalR
  • UI updates sent over WebSocket
  • IJSRuntime is scoped (not available at app startup)
  • DevTools (Singleton stores): Gracefully skips (no IJSRuntime at startup)
  • DevTools (Scoped stores): ✅ Works! (IJSRuntime available per-circuit)
  • Persistence: Limited (use server-side storage for production)

🟩 Blazor WebAssembly

  • Runs entirely in browser
  • Downloads .NET runtime to client
  • IJSRuntime always 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 (Singleton Stores)

// Singleton stores: Skip DevTools (no IJSRuntime at app startup)
builder.Services.AddStore(
    new CounterState(0),
    (store, sp) => store.WithLogging());  // Just logging

Blazor Server (Scoped Stores with DevTools!)

// Scoped stores: DevTools work! (IJSRuntime available per-circuit)
builder.Services.AddScopedStore(
    new UserSessionState(),
    (store, sp) => store
        .WithDevTools(sp, "User Session")  // ✅ Full DevTools support!
        .WithLogging());

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: Use scoped stores for DevTools support

// For per-user data with DevTools
builder.Services.AddScopedStore(
    new UserSessionState(),
    (store, sp) => store.WithDevTools(sp, "Session"));

// For shared app data (no DevTools)
builder.Services.AddStore(
    new AppConfigState(),
    (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:

  1. Add WebAssembly components: .AddInteractiveWebAssemblyComponents()
  2. Change render mode: @rendermode InteractiveAuto
  3. No code changes needed in state management!

From WASM to Auto:

  1. Add Server components: .AddInteractiveServerComponents()
  2. Change render mode: @rendermode InteractiveAuto
  3. 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!


🔒 Scoped Stores for Blazor Server (Now with Redux DevTools! 🎉)

Problem: In Blazor Server, singleton stores are shared across all SignalR circuits (all connected users). This means every client sees the same state.

Solution: Use scoped stores for per-circuit (per-user) isolation.

Breakthrough: Scoped stores now support Redux DevTools in Blazor Server! See working demo →

When to Use Scoped Stores

Scenario Store Type
User-specific data (cart, preferences, session) ✅ Scoped
Shared app data (product catalog, config) ✅ Singleton
WebAssembly apps ✅ Singleton (no sharing issue)

Scoped Store Registration

// Program.cs - Blazor Server with per-user isolation AND Redux DevTools!
builder.Services.AddStoreUtilities();

// Scoped store with DevTools - each user gets their own instance
builder.Services.AddScopedStore(
    new UserSessionState(),
    (store, sp) => store
        .WithDevTools(sp, "User Session")  // ✅ Redux DevTools work!
        .WithLogging());

// Or with utilities (async helpers) + full features
builder.Services.AddScopedStoreWithUtilities(
    new ShoppingCartState(),
    (store, sp) => store
        .WithDefaults(sp, "Shopping Cart")  // DevTools + Logging
        .WithMiddleware(customMiddleware));

Key breakthrough: Scoped stores now accept IServiceProvider in the configure callback, which enables:

  • Redux DevTools (IJSRuntime resolved per-circuit!)
  • ✅ Access to scoped services (middleware, validators)
  • ✅ Full feature parity with WebAssembly mode

Scoped Stores with Redux DevTools - The Breakthrough! 🎉

Major Discovery: Scoped stores in Blazor Server DO support Redux DevTools!

The key is that IJSRuntime is available when scoped stores are created (per-circuit), unlike singleton stores which are created at app startup before any circuits exist.

// ✅ DevTools WORK with scoped stores!
builder.Services.AddScopedStore(
    new UserSessionState(),
    (store, sp) => store
        .WithDevTools(sp, "User Session")  // ✅ Works perfectly!
        .WithLogging());

// ✅ Complete example with all features
builder.Services.AddScopedStore(
    new ShoppingCartState(),
    (store, sp) => store
        .WithDefaults(sp, "Shopping Cart")  // Includes DevTools + Logging
        .WithMiddleware(customMiddleware));

Why this works:

  1. Scoped stores are created when SignalR circuits are established
  2. IJSRuntime is available at that point (scoped per-circuit)
  3. IServiceProvider parameter enables resolving IJSRuntime
  4. Result: Full Redux DevTools support in Blazor Server! 🎊

Limitations:

  • ⚠️ LocalStorage persistence has limitations (sessions don't persist across reconnects)
  • ✅ Singleton stores still can't use DevTools (created before IJSRuntime exists)

See the Blazor Server Sample for a working demo!

Accessing Scoped Services

The new signature enables dependency injection:

// Register scoped validator
builder.Services.AddScoped<IStateValidator<CartState>, CartValidator>();

// Access it in configure callback
builder.Services.AddScopedStore(
    new CartState(),
    (store, sp) =>
    {
        var validator = sp.GetRequiredService<IStateValidator<CartState>>();
        return store.WithMiddleware(new ValidationMiddleware<CartState>(validator));
    });

Table of Contents


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 with expressions (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 State property
  • UpdateAsync() method for state transformations
  • Automatic cleanup on disposal
@inherits StoreComponent<TodoState>

<button @onclick="AddTodo">Add</button>

@code {
    async Task AddTodo() => await UpdateAsync(s => s.AddTodo("New task"));
}

Update Flow

Component calls UpdateAsync(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="@(() => UpdateAsync(s => s.ToggleTodo(todo.Id)))" />
        <span class="@(todo.Completed ? "completed" : "")">@todo.Text</span>
        <button @onclick="@(() => UpdateAsync(s => s.RemoveTodo(todo.Id)))">🗑️</button>
    </div>
}

@code {
    string newTodo = "";

    async Task HandleKeyUp(KeyboardEventArgs e)
    {
        if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(newTodo))
        {
            await UpdateAsync(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 UpdateAsync(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 UpdateAsync(...);
    _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 UpdateAsync(s => s.StartLoading());
try {
    var data = await Service.LoadAsync();
    await UpdateAsync(s => s.Success(data));
} catch (Exception ex) {
    await UpdateAsync(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>

<!-- 1. Debounced search -->
<input @oninput="@(e => UpdateDebounced(s => s.SetQuery(e.Value?.ToString() ?? ""), 300))"
       placeholder="Search..." />

<!-- 2. Load with ExecuteAsync -->
<button @onclick="Search">Search</button>

<!-- 3. Display with AsyncData -->
@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 UpdateAsync(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

  1. Batch updates instead of multiple sequential updates
  2. Split large stores into focused domains
  3. Debounce frequent updates (search, resize)
  4. Lazy load heavy data on-demand
  5. Use selectors for components that don't need all state
  6. 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 (always prefer UpdateAsync)
    protected Task UpdateAsync(Func<TState, TState> updater, string? action = null);
    protected Task UpdateAsync(Func<TState, Task<TState>> asyncUpdater, string? action = null);

    [Obsolete("Use UpdateAsync to prevent deadlock risks")]
    protected void Update(Func<TState, TState> updater, 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 UpdateAsync(Func<TState, TState> updater, string? action = null);
}

Registration Methods

// Recommended: All-in-one with utilities
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 per-user isolation)
builder.Services.AddScopedStore(
    new SessionState(),
    (store, sp) => store.WithLogging());  // IServiceProvider now available!

// Scoped stores with utilities
builder.Services.AddScopedStoreWithUtilities(
    new SessionState(),
    (store, sp) => store.WithLogging());

// Scoped with factory (for dependency injection)
builder.Services.AddScopedStore(
    sp => new CartState(sp.GetRequiredService<IUserContext>().UserId),
    (store, sp) => store.WithMiddleware(customMiddleware));

// Transient stores
builder.Services.AddTransientStoreWithUtilities(() => new TempState(), ...);

Note: The configure callback signature changed to accept IServiceProvider:

  • Old: Action<StoreBuilder<T>> (obsolete but still supported)
  • New: Func<StoreBuilder<T>, IServiceProvider, StoreBuilder<T>>

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 UpdateAsync(s => s.SetLoading(true).ClearErrors().ResetForm());

// ✅ Always use UpdateAsync (not the obsolete Update)
await UpdateAsync(s => s.Increment());

❌ 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) };
}

// ❌ Don't use synchronous Update() for cross-store updates
AuthStore.Subscribe(state =>
{
    CartStore.Update(s => s.Clear());  // Deadlock risk!
});

// ✅ Do side effects in components instead
@code {
    async Task AddItem(Item item)
    {
        Logger.LogInformation("Adding item");
        await UpdateAsync(s => s.AddItem(item));
    }
}

// ✅ Use UpdateAsync for cross-store updates
AuthStore.Subscribe(state =>
{
    _ = Task.Run(async () => await CartStore.UpdateAsync(s => s.Clear()));
});

Important: The synchronous Update() method is marked as obsolete. Always prefer UpdateAsync() to avoid deadlock risks, especially in cross-store scenarios.


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

Cross-store updates causing deadlock?

✅ Use UpdateAsync with Task.Run() pattern (never synchronous Update) ✅ Check logs for "Reentrancy detected" warnings

Seeing "Update() is obsolete" warnings?

✅ Replace Update() calls with UpdateAsync() throughout your code ✅ For cross-store scenarios, use fire-and-forget pattern: _ = Task.Run(async () => ...)

ExecuteAsync not available?

✅ Register services: builder.Services.AddStoreWithUtilities(...)

UpdateDebounced/Throttle/LazyLoad not available?

✅ Register utilities: builder.Services.AddStoreUtilities() (or use AddStoreWithUtilities)

Scoped store not working in Blazor Server?

✅ Use new signature: AddScopedStore(state, (store, sp) => ...)DevTools DO work with scoped stores! Use .WithDevTools(sp, "StoreName") ✅ Singleton stores can't use DevTools (use .WithLogging() instead)


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>

Cross-Store Updates

⚠️ Important: When updating one store from another store's subscriber, always use UpdateAsync (never synchronous Update) to prevent deadlocks.

// ✅ CORRECT: Use UpdateAsync with fire-and-forget pattern
@inject IStore<AuthState> AuthStore
@inject IStore<CartStore> CartStore

protected override void OnInitialized()
{
    // When user logs out, clear cart asynchronously
    AuthStore.Subscribe(authState =>
    {
        if (authState.CurrentUser == null)
        {
            // Fire-and-forget async update prevents deadlock
            _ = Task.Run(async () =>
                await CartStore.UpdateAsync(s => s.Clear()));
        }
    });
}

// ❌ WRONG: Synchronous Update causes deadlock
AuthStore.Subscribe(authState =>
{
    if (authState.CurrentUser == null)
        CartStore.Update(s => s.Clear());  // Deadlock! Don't do this
});

Why this matters:

  • Synchronous Update() is now obsolete due to deadlock risks
  • Always use UpdateAsync() for cross-store scenarios
  • The library detects reentrancy and logs warnings when nested updates occur

Pattern for cross-store coordination:

// Component coordinates multiple stores
async Task ProcessCheckout()
{
    // Update cart first
    await CartStore.UpdateAsync(s => s.SetProcessing());

    try
    {
        var order = await PaymentService.ProcessAsync(CartStore.GetState());

        // Update both stores after successful payment
        await OrderStore.UpdateAsync(s => s.AddOrder(order));
        await CartStore.UpdateAsync(s => s.Clear());
    }
    catch (Exception ex)
    {
        await CartStore.UpdateAsync(s => s.SetError(ex.Message));
    }
}

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 UpdateAsync(s => s.RemoveTodo(id));  // Optimistic

    try {
        await _api.DeleteAsync(id);
    } catch {
        await UpdateAsync(_ => 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

Async Helpers


What's New

v1.0.8 - Enhanced Documentation & Stability (Latest)

  • Comprehensive README with detailed Blazor render mode compatibility guide
  • Improved NuGet package documentation
  • Full support for Server, WebAssembly, and Auto modes with intelligent lazy initialization
  • Production-ready with proven stability

Redux DevTools in Blazor Server! 🎉

  • Scoped stores now support Redux DevTools in Blazor Server mode
  • IServiceProvider parameter enables IJSRuntime resolution per-circuit
  • Full feature parity with WebAssembly mode for scoped stores
  • See Blazor Server Sample for demo

Cross-Store Deadlock Prevention

  • Synchronous Update() marked as obsolete
  • Always use UpdateAsync() to prevent deadlocks
  • Reentrancy detection with warnings
  • Fire-and-forget pattern for cross-store coordination

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 setup
  • WithPersistence(sp, key) - Auto-save/restore
  • AddStoreWithUtilities() - All-in-one registration

Contributing

Contributions welcome! Keep it simple and focused.

  1. Read CODING_STANDARDS.md
  2. Run tests: dotnet test
  3. Add tests for new features
  4. Keep API surface minimal

License

MIT © EasyAppDev


About

Type-safe state management for Blazor using C# records. Includes async helpers, Redux DevTools integration, persistence, and granular selectors. Inspired by Zustand.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages

  • C# 50.8%
  • HTML 44.1%
  • CSS 2.9%
  • Python 1.0%
  • JavaScript 0.7%
  • Shell 0.3%
  • PowerShell 0.2%