State Management Patterns

Advanced patterns for organizing and managing state across your Blazor application.

Multiple Stores

Split your application state into focused, domain-specific stores rather than one monolithic store. This improves separation of concerns, makes testing easier, and allows teams to work independently.

When to Use Multiple Stores
  • Separate authentication from application data
  • Isolate feature-specific state (shopping cart, notifications, etc.)
  • Different lifecycle requirements (global vs. scoped state)
  • Team boundaries in larger applications

Registration

Register multiple stores independently in your Program.cs:

Program.cs
// Register separate stores for different domains
builder.Services.AddStoreWithUtilities(
    new AuthState(null),
    (store, sp) => store.WithDefaults(sp, "Auth"));

builder.Services.AddStoreWithUtilities(
    new CartState(ImmutableList<CartItem>.Empty),
    (store, sp) => store
        .WithDefaults(sp, "Cart")
        .WithPersistence(sp, "cart-state"));

builder.Services.AddStoreWithUtilities(
    new NotificationState(ImmutableList<Notification>.Empty),
    (store, sp) => store.WithDefaults(sp, "Notifications"));

Usage in Components

Access multiple stores by inheriting from one and injecting others:

Dashboard.razor
@page "/dashboard"
@inherits StoreComponent<AuthState>
@inject IStore<CartState> CartStore
@inject IStore<NotificationState> NotificationStore

<div class="dashboard">
    <!-- Use primary store via State property -->
    <h1>Welcome, @State.CurrentUser?.Name</h1>

    <!-- Access other stores via GetState() -->
    <div class="cart-summary">
        <p>Cart: @CartStore.GetState().ItemCount items</p>
        <p>Total: @CartStore.GetState().Total.ToString("C")</p>
    </div>

    <div class="notifications">
        @foreach (var notification in NotificationStore.GetState().Items)
        {
            <div class="notification">@notification.Message</div>
        }
    </div>
</div>

@code {
    // Primary store updates via Update()
    async Task Logout() => await Update(s => AuthState.Initial);

    // Other stores via their Update() method
    async Task ClearCart() => await CartStore.UpdateAsync(s => CartState.Empty, "CLEAR_CART");
}
Best Practice

Inherit from the store you'll interact with most frequently. Inject other stores as dependencies. This keeps your component code clean and intentional.

Derived State

Computed properties that derive values from existing state. These are calculated on-demand and don't need to be stored separately, ensuring your state stays minimal and avoiding synchronization issues.

Computed Properties

Add read-only properties to your state record for derived values:

TodoState.cs
public record TodoState(ImmutableList<Todo> Todos)
{
    public static TodoState Initial => new(ImmutableList<Todo>.Empty);

    // Derived state: calculated on access
    public int TotalCount => Todos.Count;
    public int CompletedCount => Todos.Count(t => t.Completed);
    public int ActiveCount => Todos.Count(t => !t.Completed);

    // Computed percentages
    public double CompletionRate => Todos.Count > 0
        ? (double)CompletedCount / Todos.Count * 100
        : 0;

    // Filtered collections
    public ImmutableList<Todo> ActiveTodos =>
        Todos.Where(t => !t.Completed).ToImmutableList();

    public ImmutableList<Todo> CompletedTodos =>
        Todos.Where(t => t.Completed).ToImmutableList();

    // State transformation methods
    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()
        };
}

Complex Derived State

For more complex calculations, create dedicated record types:

ShoppingCartState.cs
// Separate record for derived statistics
public record CartSummary(
    decimal Subtotal,
    decimal Tax,
    decimal Shipping,
    decimal Total,
    int ItemCount,
    bool HasItems);

public record CartState(ImmutableList<CartItem> Items)
{
    public static CartState Empty => new(ImmutableList<CartItem>.Empty);

    // Simple derived values
    public int ItemCount => Items.Sum(i => i.Quantity);
    public decimal Subtotal => Items.Sum(i => i.Product.Price * i.Quantity);

    // Complex derived state as a record
    public CartSummary Summary
    {
        get
        {
            var subtotal = Subtotal;
            var tax = subtotal * 0.08m; // 8% tax
            var shipping = subtotal > 50 ? 0 : 5.99m;
            var total = subtotal + tax + shipping;

            return new CartSummary(
                Subtotal: subtotal,
                Tax: tax,
                Shipping: shipping,
                Total: total,
                ItemCount: ItemCount,
                HasItems: Items.Count > 0
            );
        }
    }

    public CartState AddItem(Product product) { /* ... */ }
    public CartState RemoveItem(string productId) { /* ... */ }
}

Component Usage

TodoStats.razor
@inherits StoreComponent<TodoState>

<div class="stats">
    <div class="stat-card">
        <h3>@State.TotalCount</h3>
        <p>Total Tasks</p>
    </div>

    <div class="stat-card">
        <h3>@State.CompletedCount</h3>
        <p>Completed</p>
    </div>

    <div class="stat-card">
        <h3>@State.CompletionRate.ToString("F1")%</h3>
        <p>Completion Rate</p>
    </div>
</div>
Performance Consideration

Derived properties are recalculated on every access. For expensive computations accessed multiple times per render, consider using selectors with SelectorStoreComponent for memoization.

Optimistic Updates

Update the UI immediately while an async operation is in progress, then rollback if the operation fails. This provides instant feedback and a snappier user experience.

Basic Pattern

TodoList.razor
@page "/todos"
@inherits StoreComponent<TodoState>
@inject ITodoService TodoService

<!-- Component markup -->

@code {
    async Task DeleteOptimistically(Guid id)
    {
        // 1. Save current state for potential rollback
        var originalState = State;

        // 2. Apply optimistic update immediately
        await Update(s => s.RemoveTodo(id));

        try
        {
            // 3. Perform async operation
            await TodoService.DeleteAsync(id);
        }
        catch (Exception ex)
        {
            // 4. Rollback on error
            await Update(_ => originalState);

            // 5. Show error to user
            await ShowError($"Failed to delete: {ex.Message}");
        }
    }
}

With Loading States

Combine optimistic updates with loading indicators for better UX:

LikeButton.razor
@inherits StoreComponent<PostState>
@inject IPostService PostService

<button @onclick="ToggleLike" disabled="@isProcessing">
    @(State.IsLiked ? "โค๏ธ" : "๐Ÿค") @State.LikeCount
</button>

@code {
    private bool isProcessing = false;

    async Task ToggleLike()
    {
        if (isProcessing) return;

        isProcessing = true;
        var originalState = State;

        // Optimistic update
        await Update(s => s.ToggleLike());

        try
        {
            if (State.IsLiked)
                await PostService.LikeAsync(State.Id);
            else
                await PostService.UnlikeAsync(State.Id);
        }
        catch
        {
            // Rollback on failure
            await Update(_ => originalState);
        }
        finally
        {
            isProcessing = false;
        }
    }
}

Advanced: Retry Logic

OptimisticHelper.cs
public static class OptimisticUpdateHelper
{
    public static async Task<bool> ExecuteOptimistically<TState>(
        IStore<TState> store,
        Func<TState, TState> optimisticUpdate,
        Func<Task> asyncOperation,
        int maxRetries = 3) where TState : notnull
    {
        var originalState = store.GetState();

        // Apply optimistic update
        await store.UpdateAsync(optimisticUpdate);

        for (int attempt = 0; attempt < maxRetries; attempt++)
        {
            try
            {
                await asyncOperation();
                return true; // Success
            }
            catch when (attempt < maxRetries - 1)
            {
                // Retry after delay
                await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)));
            }
        }

        // All retries failed - rollback
        await store.UpdateAsync(_ => originalState);
        return false;
    }
}

// Usage
var success = await OptimisticUpdateHelper.ExecuteOptimistically(
    Store,
    s => s.RemoveTodo(id),
    () => TodoService.DeleteAsync(id)
);
When to Use Optimistic Updates
  • High-probability success: Operations that rarely fail (likes, favorites, toggles)
  • Instant feedback: User interactions that should feel immediate
  • Reversible operations: Changes that can be easily rolled back
  • Avoid for: Critical operations (payments, deletions), unreliable networks

State Composition Patterns

Compose complex state from smaller, reusable pieces. This promotes code reuse and makes state easier to test and maintain.

Nested Records

ComposedState.cs
// Composable sub-states
public record UserProfile(string Name, string Email, string AvatarUrl);

public record UserPreferences(
    bool DarkMode,
    string Language,
    bool EmailNotifications);

public record UserStats(
    int PostCount,
    int FollowerCount,
    DateTime LastActive);

// Composed state
public record UserState(
    UserProfile Profile,
    UserPreferences Preferences,
    UserStats Stats,
    AsyncData<ImmutableList<Post>> Posts)
{
    public static UserState Initial => new(
        Profile: new("", "", ""),
        Preferences: new(false, "en", true),
        Stats: new(0, 0, DateTime.UtcNow),
        Posts: AsyncData<ImmutableList<Post>>.NotAsked()
    );

    // Update specific sub-states
    public UserState UpdateProfile(UserProfile profile) =>
        this with { Profile = profile };

    public UserState ToggleDarkMode() =>
        this with {
            Preferences = Preferences with {
                DarkMode = !Preferences.DarkMode
            }
        };

    public UserState IncrementPostCount() =>
        this with {
            Stats = Stats with {
                PostCount = Stats.PostCount + 1,
                LastActive = DateTime.UtcNow
            }
        };
}

Shared State Behaviors

Create reusable state patterns with extension methods:

StateExtensions.cs
// Reusable pagination pattern
public record PaginatedData<T>(
    ImmutableList<T> Items,
    int CurrentPage,
    int PageSize,
    int TotalItems)
{
    public int TotalPages => (int)Math.Ceiling((double)TotalItems / PageSize);
    public bool HasPreviousPage => CurrentPage > 1;
    public bool HasNextPage => CurrentPage < TotalPages;
}

public static class PaginatedDataExtensions
{
    public static PaginatedData<T> NextPage<T>(this PaginatedData<T> data) =>
        data.HasNextPage
            ? data with { CurrentPage = data.CurrentPage + 1 }
            : data;

    public static PaginatedData<T> PreviousPage<T>(this PaginatedData<T> data) =>
        data.HasPreviousPage
            ? data with { CurrentPage = data.CurrentPage - 1 }
            : data;

    public static PaginatedData<T> GoToPage<T>(this PaginatedData<T> data, int page) =>
        page >= 1 && page <= data.TotalPages
            ? data with { CurrentPage = page }
            : data;
}

// Usage in state
public record ProductState(PaginatedData<Product> Products)
{
    public ProductState NextPage() =>
        this with { Products = Products.NextPage() };

    public ProductState PreviousPage() =>
        this with { Products = Products.PreviousPage() };
}

Best Practices

Organizing State

๐Ÿ“‚ Group by Domain, Not by Type

Organize state files by feature domain (Auth, Cart, User) rather than technical type (States, Actions).

๐ŸŽฏ Keep Stores Focused

Each store should have a single, clear responsibility. Split large stores into smaller, focused ones.

๐Ÿ”„ Use Static Initial Values

Define static Initial or static Empty properties for default state values.

๐Ÿ’ก Prefer Derived State Over Duplication

Calculate values from existing state rather than storing them separately to avoid sync issues.

๐Ÿงช Test State Methods Independently

State methods are pure functions - test them without mocks or component infrastructure.

Anti-Patterns to Avoid

// โŒ DON'T: Duplicate derived data
public record BadState(
    ImmutableList<Todo> Todos,
    int CompletedCount,  // Duplicates what can be calculated
    int TotalCount       // Duplicates what can be calculated
);

// โœ… DO: Calculate on access
public record GoodState(ImmutableList<Todo> Todos)
{
    public int CompletedCount => Todos.Count(t => t.Completed);
    public int TotalCount => Todos.Count;
}

// โŒ DON'T: Store UI state globally
public record BadAppState(
    UserData User,
    bool IsModalOpen,      // UI state
    int SelectedTabIndex   // UI state
);

// โœ… DO: Keep UI state local to components
public record GoodAppState(UserData User);
// Component: private bool isModalOpen = false;

// โŒ DON'T: Deeply nested updates
public UserState UpdateNestedProperty(string value) =>
    this with {
        Profile = Profile with {
            Settings = Settings with {
                Privacy = Privacy with {
                    EmailVisible = value
                }
            }
        }
    };

// โœ… DO: Create focused update methods
public UserState UpdateEmailVisibility(bool visible) =>
    this with {
        Profile = Profile.WithEmailVisibility(visible)
    };

Next Steps