Persistent Component State in Blazor

01.The State-Loss Problem

Blazor components are stateful in memory for as long as the render tree that owns them exists. That render tree can be torn down by navigation, reconnection after a dropped SignalR circuit, a page reload, or the transition from server-side pre-rendering to interactive rendering. Any field that was not persisted elsewhere is gone.

The specific scenarios that destroy in-memory component state:

Note PersistentComponentState solves specifically the prerender → interactive gap. For the other scenarios, different mechanisms apply.

02.PersistentComponentState

PersistentComponentState is an injected service that lets a component serialize arbitrary data into the HTML payload during prerender. When the interactive runtime boots, it can deserialize that data instead of re-running the fetch.

Registration

The service is available automatically — no explicit registration needed. Inject it in the component.

Razor WeatherForecast.razor
@inject PersistentComponentState AppState

@code {
    private PersistingComponentStateSubscription _subscription;
    private WeatherData[]? _forecasts;

    protected override async Task OnInitializedAsync()
    {
        // Subscribe to the "persist" event before anything else.
        _subscription = AppState.RegisterOnPersisting(Persist);

        if (!AppState.TryTakeFromJson<WeatherData[]>("forecasts", out var cached))
        {
            // Not available from prerender — fetch it.
            _forecasts = await WeatherService.GetForecastsAsync();
        }
        else
        {
            _forecasts = cached;
        }
    }

    private Task Persist()
    {
        // Called by the framework just before serializing HTML.
        // Only runs during prerender.
        AppState.PersistAsJson("forecasts", _forecasts);
        return Task.CompletedTask;
    }

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

What happens under the hood

PersistAsJson writes a JSON blob into a hidden <script> element embedded in the page HTML. TryTakeFromJson reads and removes it. After the first successful take, the data is gone — it is consumed exactly once.

Warning Keys are global within the page. If you render the same component multiple times (e.g. inside a list), each instance will collide on the same key. Use a unique key per instance, typically derived from a parameter (e.g. $"forecast-{Id}").

Constraints

03.URL as State

Query strings and route parameters survive navigation, reloads, and sharing. For filter values, pagination, selected tab, or any UI state that should be bookmarkable, the URL is the right store.

Route parameters

Razor ProductDetail.razor
@page "/products/{Id:int}"

@code {
    [Parameter] public int Id { get; set; }
}

Query string parameters (.NET 8+)

Razor ProductList.razor
@page "/products"

@code {
    [SupplyParameterFromQuery]
    [Parameter]
    public int Page { get; set; } = 1;

    [SupplyParameterFromQuery(Name = "q")]
    [Parameter]
    public string? SearchTerm { get; set; }
}

Updating the URL without full navigation

C#
// NavigationManager.NavigateTo with replace:true avoids a browser history entry.
NavigationManager.NavigateTo(
    NavigationManager.GetUriWithQueryParameter("Page", newPage),
    replace: true
);
Tip GetUriWithQueryParameter and GetUriWithQueryParameters merge into the existing URL rather than replacing it wholesale. Use them to preserve other query keys the component didn't set.

Constraints

04.Browser Storage via JS Interop

localStorage and sessionStorage survive page reloads (localStorage also survives tab close) and are accessible across all Blazor hosting models. Access requires JS interop and therefore cannot run during SSR prerender.

Minimal abstraction

C# BrowserStorageService.cs
public class BrowserStorageService(IJSRuntime js)
{
    public ValueTask SetAsync(string key, string value) =>
        js.InvokeVoidAsync("localStorage.setItem", key, value);

    public ValueTask<string?> GetAsync(string key) =>
        js.InvokeAsync<string?>("localStorage.getItem", key);

    public ValueTask RemoveAsync(string key) =>
        js.InvokeVoidAsync("localStorage.removeItem", key);
}

For typed objects, serialize with System.Text.Json before storing:

C#
await Storage.SetAsync(
    "user-prefs",
    JsonSerializer.Serialize(prefs)
);

var json = await Storage.GetAsync("user-prefs");
if (json is not null)
    prefs = JsonSerializer.Deserialize<UserPreferences>(json);

Prerender guard

JS interop throws during SSR prerender. Guard with OnAfterRenderAsync(firstRender), which only fires in an interactive context:

Razor
@code {
    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            _theme = await Storage.GetAsync("theme") ?? "dark";
            StateHasChanged();
        }
    }
}
Warning Reading from storage in OnAfterRenderAsync causes a second render. If the initial render paints different state than post-storage-read (e.g. "light" theme flashing "dark" first), users will see a flash. Mitigate with a CSS transition or a hidden skeleton during first render.

Blazor WASM: ProtectedLocalStorage

In WASM, Microsoft.AspNetCore.Components.Server.ProtectedBrowserStorage is not available (it relies on data protection on the server). Use plain JS interop, or a package like Blazored.LocalStorage which wraps the same pattern with stronger typing and DI integration.

On Blazor Server, ProtectedLocalStorage is available and encrypts values server-side before storing:

C#
// Blazor Server only. Inject ProtectedLocalStorage (registered automatically).
await ProtectedStorage.SetAsync("cart", cartItems);
var result = await ProtectedStorage.GetAsync<CartItem[]>("cart");
if (result.Success) cartItems = result.Value;

05.Cascading Values and Parameters

Cascading values let a parent push state down to an arbitrary depth of descendants without threading parameters through every intermediate component. The canonical example is a theme or authenticated user, but the pattern works for any shared state that a subtree needs to read.

Basic usage

Razor App.razor
<!-- Push a value down the entire component tree -->
<CascadingValue Value="_currentUser">
    <Router AppAssembly="typeof(App).Assembly">
        <!-- ... -->
    </Router>
</CascadingValue>

@code {
    private AppUser? _currentUser;
}
Razor UserAvatar.razor (any depth descendant)
@code {
    [CascadingParameter]
    public AppUser? CurrentUser { get; set; }
}

Named cascading values

When multiple values share the same type, use names to disambiguate:

Razor
<CascadingValue Name="Theme" Value="_theme">
<CascadingValue Name="Locale" Value="_locale">

<!-- Consumer -->
[CascadingParameter(Name = "Theme")]
public string Theme { get; set; } = default!;

Reactive cascading via a state container

A plain CascadingValue re-renders all subscribers whenever any ancestor re-renders, because the reference may have changed. To propagate changes explicitly and avoid unnecessary re-renders, cascade a service that exposes an event:

C# ThemeState.cs
public class ThemeState
{
    private string _theme = "dark";

    public string Theme => _theme;
    public event Action? OnChange;

    public void SetTheme(string theme)
    {
        _theme = theme;
        OnChange?.Invoke();
    }
}
Razor Consumer
@inject ThemeState ThemeState
@implements IDisposable

@code {
    protected override void OnInitialized()
        => ThemeState.OnChange += StateHasChanged;

    public void Dispose()
        => ThemeState.OnChange -= StateHasChanged;
}
Note Set IsFixed="true" on <CascadingValue> when the value never changes after initial render. This skips the overhead of tracking it for change detection across the entire subtree.

06.Scoped Services as State Containers

A scoped DI service in Blazor has a lifetime tied to the circuit (Blazor Server) or the browser tab session (Blazor WASM). It survives navigation between pages because the DI container is not torn down by routing. It does not survive reload or circuit reconnect.

This makes scoped services the right tool for state that needs to outlast a single component but does not need to survive a reload — shopping carts, wizard step data, accumulated form state.

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

    public IReadOnlyList<CartItem> Items => _items.AsReadOnly();
    public event Action? OnCartChanged;

    public void Add(CartItem item)
    {
        _items.Add(item);
        OnCartChanged?.Invoke();
    }

    public void Remove(CartItem item)
    {
        _items.Remove(item);
        OnCartChanged?.Invoke();
    }
}
C# Program.cs
// Scoped per circuit (Server) or per tab (WASM)
builder.Services.AddScoped<CartService>();
Danger On Blazor Server, never register state in a singleton service that stores per-user data. Singletons are shared across all users on the server. Use scoped services, or key your singleton state by circuit ID / user ID explicitly.

Blazor Server circuit scope vs Blazor WASM

Lifetime Blazor Server Blazor WASM
Singleton Entire server process (shared across all users) Browser tab session
Scoped Single SignalR circuit Browser tab session (same as singleton)
Transient Per DI resolve Per DI resolve

07.Mechanism Comparison

Mechanism Survives navigation Survives reload Survives circuit drop Works during SSR Scope
PersistentComponentState No No No Yes Prerender → interactive only
URL / query string Yes Yes Yes Yes Scalar values, visible to user
localStorage Yes Yes Yes No Origin-scoped, persists until cleared
sessionStorage Yes No Tab only No Tab-scoped, cleared on tab close
Cascading value Yes No No Yes Component subtree
Scoped DI service Yes No No Yes Circuit (Server) or tab (WASM)
Server-side session/DB Yes Yes Yes Yes User identity — requires auth

08.Common Patterns

Pattern: Prerender + interactive with no double-fetch

Combine PersistentComponentState (for the initial load) with a scoped service (for subsequent navigation). On first load the service is empty so the component fetches and persists. After interactive boot the service holds the data for any component that needs it without re-fetching from the network.

C# Sequence
// 1. SSR prerender: Component.OnInitializedAsync()
//    → DataService.Items is empty
//    → fetch from API
//    → store in DataService (scoped) + PersistentComponentState

// 2. Interactive boot: Component.OnInitializedAsync()
//    → TryTakeFromJson → data present (from HTML payload)
//    → skip API call
//    → populate DataService

// 3. Navigate to another page, navigate back:
//    → DataService.Items still populated (scoped service survived navigation)
//    → skip API call

Pattern: Restoring form state across reloads

For multi-step forms where losing state on accidental reload is unacceptable, serialize form state to sessionStorage on every field change and restore in OnAfterRenderAsync:

Razor
@code {
    private ApplicationForm _form = new();

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            var json = await JS.InvokeAsync<string?>(
                "sessionStorage.getItem", "application-form");

            if (json is not null)
            {
                _form = JsonSerializer.Deserialize<ApplicationForm>(json)!;
                StateHasChanged();
            }
        }
    }

    private async Task OnFieldChanged()
    {
        await JS.InvokeVoidAsync(
            "sessionStorage.setItem",
            "application-form",
            JsonSerializer.Serialize(_form));
    }
}

Pattern: Global notification / toast state

Register a singleton ToastService on WASM, or scoped on Server, that holds a queue of messages and exposes an event. A ToastContainer component at the root subscribes to the event and re-renders. Any component anywhere in the tree injects the service and pushes a message without needing to know where the container is.

C#
public class ToastService
{
    private readonly Queue<ToastMessage> _queue = new();
    public event Action? OnToast;
    public IEnumerable<ToastMessage> Messages => _queue;

    public void Show(string message, ToastSeverity severity = ToastSeverity.Info)
    {
        _queue.Enqueue(new(message, severity));
        OnToast?.Invoke();
    }

    public void Dismiss() { _queue.TryDequeue(out _); OnToast?.Invoke(); }
}
Tip When a service raises an event from a background thread (e.g. SignalR message handler), the subscriber must invoke InvokeAsync(StateHasChanged) via the component's dispatcher rather than calling StateHasChanged directly, which is not thread-safe on Blazor Server.
C#
protected override void OnInitialized()
    => HubService.OnMessage += HandleMessage;

private void HandleMessage(ChatMessage msg)
{
    _messages.Add(msg);
    // Thread-safe re-render from background SignalR callback
    InvokeAsync(StateHasChanged);
}