Mastering Blazor State Management: A Comprehensive Guide for Beginners

Blazor State Management: Strategies & C# Examples

Learn Blazor state management: component vs app state, DI containers, storage, and Redux‑style stores—complete with practical C# code.

.NET Development·By amarozka · September 25, 2025

Blazor State Management: Strategies & C# Examples

Are you sure your Blazor app isn’t silently losing state on navigation, refresh, or reconnect? I’ve seen production apps “forget” user choices simply because state lived in the wrong place. Let’s fix that – today.

What “state” really means in Blazor (and why it’s tricky)

In UI apps, state is any data that influences rendering: form inputs, filters, auth info, shopping carts, feature flags, etc. In Blazor you get multiple lifetimes and rendering modes (WASM, Server, SSR/interactive) that change how and where state should live. Put it in the wrong lifetime and you’ll fight random resets, cross-user leaks, or re-render storms.

Typical state scopes in Blazor:

  • Component state – private fields inside a .razor component. Short‑lived, resets on disposal.
  • Cascading state – passed through the component tree via <CascadingValue>. Great for themes, culture, auth context.
  • App state (DI services) – a scoped/singleton service storing data for the user/session/app.
  • Browser storagelocalStorage / sessionStorage (WASM) or via ProtectedLocalStorage/ProtectedSessionStorage (Server/WASM) for persistence.
  • Backend state – database/Redis/session, often accessed via APIs or EF Core.

Rule of thumb:

Keep state as close to the place that uses it as possible, but no closer. If multiple pages/components need it, promote it to a DI service. If it must survive page reloads, persist it. If it’s user-specific on Blazor Server, avoid Singleton.

Choosing the right lifetime (WASM vs Server)

Blazor WebAssembly (WASM)

  • Runs entirely in the browser; Scoped services behave like singletons for the entire app lifetime.
  • Browser refresh wipes memory; use storage for persistence.

Blazor Server

  • Your app runs on the server; a circuit represents each user connection.
  • Scoped services are per‑circuit (per user session). Singleton is shared across users – be careful.
  • If the circuit is lost and reconnected, you can lose in‑memory state. Persist what must survive.

Strategy 1: Local component state (fastest path)

Use when only one component cares about the data and it can be recomputed.

Example: Todo list (component‑only)

@page "/todo"
<h3>Todo</h3>
<input @bind="newItem" @onkeydown="HandleEnter" placeholder="Add item" />
<button @onclick="Add">Add</button>

<ul>
    @foreach (var item in items)
    {
        <li>@item</li>
    }
</ul>

@code {
    private List<string> items = new();
    private string? newItem;

    private void Add()
    {
        if (!string.IsNullOrWhiteSpace(newItem))
        {
            items.Add(newItem);
            newItem = string.Empty;
        }
    }

    private void HandleEnter(KeyboardEventArgs e)
    {
        if (e.Key == "Enter") Add();
    }
}

Pros: simple, no DI. Cons: gone on navigation/refresh.

Strategy 2: Cascading values for cross‑tree state

Great for themes, culture, or a current user. Avoid stuffing too much here – updates trigger re‑renders down the tree.

Example: Theme via CascadingValue

<!-- App.razor -->
<CascadingValue Value="theme" IsFixed="false">
    <Router AppAssembly="typeof(App).Assembly" />
</CascadingValue>

@code {
    private Theme theme = new("light");
}

public record Theme(string Name)
{
    public Theme Toggle() => this with { Name = Name == "light" ? "dark" : "light" };
}
<!-- NavMenu.razor -->
<CascadingParameter] Theme Theme { get; set; } = default!;
<button @onclick="Toggle">Switch to @(Theme.Name == "light" ? "dark" : "light")</button>

@code {
    [CascadingParameter] public Theme Theme { get; set; } = default!;
    [CascadingParameter(Name = nameof(theme))] private Theme theme { get; set; } = default!; // alt style

    [CascadingParameter] public Action<Theme>? SetTheme { get; set; } // if you cascade a setter/delegate too

    private void Toggle() => SetTheme?.Invoke(Theme.Toggle());
}

Tip: if you need to mutate, either cascade a mutable object with change notifications or cascade a setter delegate with the immutable value.

Strategy 3: App‑level state container (Scoped service)

When several pages must share state (cart, filters, wizard steps), use a scoped DI service and raise change notifications.

State container service

public sealed class AppState
{
    private int _count;
    public int Count => _count;

    public event Action? Changed;

    public void Increment()
    {
        _count++;
        Changed?.Invoke();
    }

    public void Set(int value)
    {
        _count = value;
        Changed?.Invoke();
    }
}

Registration

// Program.cs
builder.Services.AddScoped<AppState>();

Component usage

@inject AppState State
@implements IDisposable

<h3>Count: @State.Count</h3>
<button @onclick="State.Increment">+</button>

@code {
    protected override void OnInitialized()
    {
        State.Changed += OnStateChanged;
    }

    private void OnStateChanged() => InvokeAsync(StateHasChanged);

    public void Dispose() => State.Changed -= OnStateChanged;
}

Why not INotifyPropertyChanged alone? Blazor doesn’t automatically subscribe; you must call StateHasChanged() (via an event like above) to re-render.

Blazor Server caution: keep per‑user state in Scoped services, not Singleton, to avoid cross‑user leaks.

Strategy 4: Persist user state to storage (survive refresh)

Use for preferences, carts, or “remember me” flows.

Using ProtectedLocalStorage

Works in both Server and WASM; values are protected via data protection (Server) or encryption plumbing.

@page "/persisted-counter"
@using Microsoft.AspNetCore.Components.Server.ProtectedBrowserStorage
@inject AppState State
@inject ProtectedLocalStorage Storage
@implements IDisposable

<h3>Persisted Count: @State.Count</h3>
<button @onclick="State.Increment">+</button>

@code {
    protected override async Task OnInitializedAsync()
    {
        State.Changed += PersistAsync;
        var result = await Storage.GetAsync<int>("count");
        if (result.Success) State.Set(result.Value);
    }

    private async void PersistAsync()
    {
        try { await Storage.SetAsync("count", State.Count); }
        finally { await InvokeAsync(StateHasChanged); }
    }

    public void Dispose() => State.Changed -= PersistAsync;
}

Using JS interop for sessionStorage

public interface IStorage
{
    ValueTask SetAsync(string key, string value);
    ValueTask<string?> GetAsync(string key);
}

public sealed class SessionStorageJs(IJSRuntime js) : IStorage
{
    public ValueTask SetAsync(string key, string value) => js.InvokeVoidAsync("sessionStorage.setItem", key, value);
    public async ValueTask<string?> GetAsync(string key) => await js.InvokeAsync<string?>("sessionStorage.getItem", key);
}

Registration:

builder.Services.AddScoped<IStorage, SessionStorageJs>();

Usage in a component/service: serialize complex objects with System.Text.Json.

Strategy 5: A tiny Redux‑style store (immutable, predictable)

If your app grows and state transitions become messy, move to a single state tree with immutable updates and a Dispatch method. No library needed (though Fluxor is popular), but the pattern matters.

public interface IAction { }
public sealed record Increment(int Amount) : IAction;
public sealed record Reset : IAction;

public sealed record CounterState(int Count)
{
    public static CounterState Reduce(CounterState state, IAction action) => action switch
    {
        Increment i => state with { Count = state.Count + i.Amount },
        Reset => new(0),
        _ => state
    };
}

public sealed class Store
{
    public CounterState State { get; private set; } = new(0);
    public event Action? Changed;

    public void Dispatch(IAction action)
    {
        var next = CounterState.Reduce(State, action);
        if (!ReferenceEquals(next, State))
        {
            State = next;
            Changed?.Invoke();
        }
    }
}

Register Store as Scoped; inject and subscribe in components the same way as AppState.

Benefits: predictable, testable reducers; easy undo/redo; time‑travel debugging if you log actions.

Strategy 6: Async data loading without flicker or leaks

When route parameters change or users type into a search box, cancel previous requests to avoid racing.

@page "/products/{q?}"
@inject HttpClient Http

@if (loading) { <p>Loading…</p> }
else if (error is not null) { <p class="text-danger">@error</p> }
else { <ProductList Items="items" /> }

@code {
    [Parameter] public string? q { get; set; }
    private List<Product> items = new();
    private bool loading;
    private string? error;
    private CancellationTokenSource? cts;

    protected override async Task OnParametersSetAsync()
    {
        cts?.Cancel();
        cts = new();
        loading = true; error = null;
        try
        {
            items = await Http.GetFromJsonAsync<List<Product>>($"api/products?q={Uri.EscapeDataString(q ?? "")}", cts.Token) ?? [];
        }
        catch (OperationCanceledException) { /* ignored */ }
        catch (Exception ex) { error = ex.Message; }
        finally { loading = false; }
    }
}

Notes:

  • Use OnParametersSetAsync for parameter‑driven loads.
  • Cancel previous requests. Avoid updating the UI after disposal (ObjectDisposedException).

Strategy 7: Control rendering to avoid UI storms

Blazor re‑renders when parameters change, events occur, or you call StateHasChanged(). Use these levers:

  • ShouldRender() – return false to skip a re‑render when updates don’t affect the markup.
  • RenderFragment children – pass child content to avoid re‑rendering large subtrees.
  • Debounce frequent events (oninput) using a timer.

Example: ShouldRender

@code {
    private int unchangedCounter;
    protected override bool ShouldRender() => unchangedCounter == 0; // trivial demo
}

Strategy 8: Error boundaries & resilient state

Use <ErrorBoundary> to catch render exceptions and keep the rest of the app alive. Reset state or show a fallback.

<ErrorBoundary>
    <ChildThatMightThrow />
    <ErrorContent>
        <p>Something went wrong. Try again.</p>
    </ErrorContent>
</ErrorBoundary>

Putting it together: a mini app with shared + persisted state

1) State container

public sealed class CartState
{
    private readonly List<CartItem> _items = new();
    public IReadOnlyList<CartItem> Items => _items;
    public event Action? Changed;

    public void Add(CartItem item)
    {
        var existing = _items.FirstOrDefault(i => i.Id == item.Id);
        if (existing is null) _items.Add(item);
        else existing.Quantity += item.Quantity;
        Changed?.Invoke();
    }

    public void Clear() { _items.Clear(); Changed?.Invoke(); }
}

public sealed record CartItem(string Id, string Name, decimal Price, int Quantity);

2) Persistence service

public sealed class CartPersistence(CartState state, ProtectedLocalStorage storage)
{
    private const string Key = "cart";

    public async Task LoadAsync()
    {
        var result = await storage.GetAsync<List<CartItem>>(Key);
        if (result.Success && result.Value is { } items)
        {
            foreach (var i in items) state.Add(i);
        }
    }

    public async Task SaveAsync()
    {
        await storage.SetAsync(Key, state.Items.ToList());
    }
}

3) Wire up in Program.cs

builder.Services.AddScoped<CartState>();
builder.Services.AddScoped<CartPersistence>();

4) Hook persistence

@inject CartState Cart
@inject CartPersistence Persistence
@implements IDisposable

<Router AppAssembly="typeof(App).Assembly" />

@code {
    protected override async Task OnInitializedAsync()
    {
        Cart.Changed += Persist;
        await Persistence.LoadAsync();
    }

    private async void Persist() => await Persistence.SaveAsync();
    public void Dispose() => Cart.Changed -= Persist;
}

Result: Cart survives refresh, shared across pages, no accidental cross‑user leakage on Server because it’s scoped.

Anti‑patterns I’ve removed from real projects

  • Singleton user state on Blazor Server – leaks across users. Use Scoped.
  • Static fields for mutable UI state – hard to test, global coupling.
  • async void event handlers that throw – process deaths or swallowed exceptions.
  • Updating state without notifying UI – call StateHasChanged() (via event subscription) or nothing re‑renders.
  • Heavy objects in CascadingValue – causes large subtrees to re‑render on every tiny change.

Testing and debugging your state

  • Unit test reducers/services: they’re plain classes – no renderer needed.
  • Action log: wrap Store.Dispatch to log actions; replay to reproduce bugs.
  • Diagnostic render: temporarily render @DateTime.Now.Ticks to verify re‑render timing.
  • DevTools storage tab: verify storage writes.

Quick decision matrix

Need only inside one component?          → Component fields
Across a subtree?                        → CascadingValue (or context provider)
Across pages for the same user?          → Scoped DI state container (events)
Must survive refresh?                    → ProtectedLocal/SessionStorage or backend
Complex transitions/many features?       → Redux-style store (immutable, reducers)
Blazor Server with reconnect risks?      → Persist critical state; keep scope per circuit

FAQ: Blazor state headaches answered

Why does my state reset after I hit F5?

A refresh wipes in‑memory state (WASM and Server). Persist to storage or backend.

Do I need Fluxor/Redux for every app?

No. Start simple (scoped state + events). Adopt Redux‑style when transitions explode in number or you need time‑travel/debugging.

How do I prevent re‑render storms?

Use ShouldRender, split components, debounce inputs, and avoid cascading heavy mutable objects.

What about authentication state?

Use AuthenticationStateProvider (built‑in). Don’t roll your own for auth claims.

Server vs WASM – what changes?

Lifetimes. Scoped = app lifetime in WASM, but scoped = per circuit on Server. Persist critical state on Server to handle reconnects.

Can I put DbContext in my state container?

Avoid it in long‑lived containers. Keep DbContext lifetime short (per operation/request) and map data to plain records/DTOs.

How do I share state between Razor Components and MVC/Minimal APIs?

Use the backend as the source of truth (database/Redis) and query/update via APIs. UI state mirrors backend state, not the other way around.

Conclusion: Stable Blazor apps come from the right state in the right place

If you pick the proper lifetime (component, cascade, scoped, persisted) and wire change notifications carefully, Blazor becomes predictable and pleasant. Start small, add persistence only where needed, and upgrade to a Redux‑style store when your app’s complexity demands it.

Which part of your app loses state today – filters, cart, wizard? Drop a comment and I’ll propose a refactor plan tailored to your case.

2 Comments

    • Yes sure, StateHasChanged method is used in Blazor to signal the framework to check a component for state changes and then re-render if necessary. Usually it called automatically, but in some cases, for example after async operation, you might need to call it manually.

Leave a Reply

Your email address will not be published. Required fields are marked *