State Management Patterns in Blazor

Blazor doesn't prescribe a state management approach. Components have their own local state, and that's often enough. But as your application grows, you'll need patterns for sharing state between components, persisting state across navigations, and keeping the UI in sync with data changes. Here are the practical approaches, from simplest to most structured.

Level 1: Component State

The simplest form of state — fields in your @code block:

razor
@page "/counter"

<p>Count: @count</p>
<button @onclick="Increment">Increment</button>

@code {
    private int count = 0;
    private void Increment() => count++;
}

This works perfectly for self-contained components. The state lives and dies with the component.

Level 2: State Container Service

When multiple components need to share state, create a service that holds the state and notifies consumers of changes:

CartState.cs
public class CartState
{
    private readonly List<CartItem> _items = [];

    public IReadOnlyList<CartItem> Items => _items.AsReadOnly();
    public decimal Total => _items.Sum(i => i.Price * i.Quantity);

    public event Action? OnChange;

    public void AddItem(CartItem item)
    {
        var existing = _items.FirstOrDefault(i => i.ProductId == item.ProductId);
        if (existing is not null)
        {
            existing.Quantity += item.Quantity;
        }
        else
        {
            _items.Add(item);
        }

        NotifyStateChanged();
    }

    public void RemoveItem(int productId)
    {
        _items.RemoveAll(i => i.ProductId == productId);
        NotifyStateChanged();
    }

    private void NotifyStateChanged() => OnChange?.Invoke();
}

Register it with the appropriate lifetime:

Example.cs
// Scoped = per-circuit in Server, per-tab in WebAssembly
builder.Services.AddScoped<CartState>();

Components subscribe to changes:

razor
@inject CartState Cart
@implements IDisposable

<span>Cart: @Cart.Items.Count items (@Cart.Total.ToString("C"))</span>

@code {
    protected override void OnInitialized()
    {
        Cart.OnChange += StateHasChanged;
    }

    public void Dispose()
    {
        Cart.OnChange -= StateHasChanged;
    }
}

This pattern scales well for most applications. Each domain area gets its own state container, and components subscribe only to the containers they care about.

Level 3: Browser Storage

For state that should survive page reloads or browser restarts, use browser storage. The ProtectedBrowserStorage service provides encrypted access to sessionStorage and localStorage:

razor
@inject ProtectedSessionStorage SessionStorage

@code {
    private UserPreferences? preferences;

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            var result = await SessionStorage.GetAsync<UserPreferences>("prefs");
            preferences = result.Success ? result.Value : new UserPreferences();
            StateHasChanged();
        }
    }

    private async Task SavePreferences()
    {
        await SessionStorage.SetAsync("prefs", preferences!);
    }
}

Use ProtectedLocalStorage for data that should persist across sessions, and ProtectedSessionStorage for data that should clear when the tab closes.

Note that browser storage is only available after the first render — you cannot access it during OnInitializedAsync in prerendered components.

Level 4: PersistentComponentState

For persisting state across the prerender boundary (when the component renders on the server, then re-renders on the client), use PersistentComponentState:

razor
@inject PersistentComponentState ApplicationState

@code {
    private List<Product>? products;
    private PersistingComponentStateSubscription _sub;

    protected override async Task OnInitializedAsync()
    {
        _sub = ApplicationState.RegisterOnPersisting(Persist);

        if (!ApplicationState.TryTakeFromJson<List<Product>>("products", out products))
        {
            products = await Api.GetProductsAsync();
        }
    }

    private Task Persist()
    {
        ApplicationState.PersistAsJson("products", products);
        return Task.CompletedTask;
    }

    public void Dispose() => _sub.Dispose();
}

This prevents the double-fetch problem where data is loaded during prerendering and then loaded again when the interactive circuit starts.

Level 5: Flux/Redux Pattern

For complex applications with many interacting state changes, a Flux-style pattern provides predictability through unidirectional data flow. The Fluxor library is the most popular implementation for Blazor:

Example.cs
// State
[FeatureState]
public record CounterState(int Count)
{
    public CounterState() : this(0) { }
}

// Actions
public record IncrementAction;
public record IncrementByAction(int Amount);

// Reducers
public static class CounterReducers
{
    [ReducerMethod]
    public static CounterState OnIncrement(CounterState state, IncrementAction action)
        => state with { Count = state.Count + 1 };

    [ReducerMethod]
    public static CounterState OnIncrementBy(CounterState state, IncrementByAction action)
        => state with { Count = state.Count + action.Amount };
}

// Effects (side effects like API calls)
public class CounterEffects
{
    private readonly ICounterApi _api;

    public CounterEffects(ICounterApi api) => _api = api;

    [EffectMethod]
    public async Task HandleIncrementAction(IncrementAction action, IDispatcher dispatcher)
    {
        await _api.LogIncrementAsync();
    }
}

Using it in a component:

razor
@inherits Fluxor.Blazor.Web.Components.FluxorComponent
@inject IState<CounterState> CounterState
@inject IDispatcher Dispatcher

<p>Count: @CounterState.Value.Count</p>
<button @onclick="Increment">+1</button>
<button @onclick="IncrementByTen">+10</button>

@code {
    private void Increment() => Dispatcher.Dispatch(new IncrementAction());
    private void IncrementByTen() => Dispatcher.Dispatch(new IncrementByAction(10));
}

This is powerful but adds significant ceremony. Use it when you have complex state interactions, need time-travel debugging, or when multiple components modify the same state in non-trivial ways.

Choosing the Right Level

Start simple. Most Blazor applications work perfectly well with scoped state container services. Graduate to more structured approaches only when the simpler ones start causing pain.