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:
PersistentComponentState solves specifically the prerender → interactive gap. For the other scenarios, different mechanisms apply.
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.
The service is available automatically — no explicit registration needed. Inject it in the component.
@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(); }
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.
$"forecast-{Id}").
JsonSerializerOptions registered on IComponentSerializer.OnInitializedAsync, before any await that might cause a render.Dispose the subscription — memory leak if you don't.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.
@page "/products/{Id:int}" @code { [Parameter] public int Id { get; set; } }
@page "/products" @code { [SupplyParameterFromQuery] [Parameter] public int Page { get; set; } = 1; [SupplyParameterFromQuery(Name = "q")] [Parameter] public string? SearchTerm { get; set; } }
// NavigationManager.NavigateTo with replace:true avoids a browser history entry. NavigationManager.NavigateTo( NavigationManager.GetUriWithQueryParameter("Page", newPage), replace: true );
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.
OnParametersSetAsync, not a re-navigation to a new component instance, unless the route segment changes type.
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.
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:
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);
JS interop throws during SSR prerender. Guard with OnAfterRenderAsync(firstRender),
which only fires in an interactive context:
@code { protected override async Task OnAfterRenderAsync(bool firstRender) { if (firstRender) { _theme = await Storage.GetAsync("theme") ?? "dark"; StateHasChanged(); } } }
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.
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:
// 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;
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.
<!-- Push a value down the entire component tree --> <CascadingValue Value="_currentUser"> <Router AppAssembly="typeof(App).Assembly"> <!-- ... --> </Router> </CascadingValue> @code { private AppUser? _currentUser; }
@code { [CascadingParameter] public AppUser? CurrentUser { get; set; } }
When multiple values share the same type, use names to disambiguate:
<CascadingValue Name="Theme" Value="_theme"> <CascadingValue Name="Locale" Value="_locale"> <!-- Consumer --> [CascadingParameter(Name = "Theme")] public string Theme { get; set; } = default!;
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:
public class ThemeState { private string _theme = "dark"; public string Theme => _theme; public event Action? OnChange; public void SetTheme(string theme) { _theme = theme; OnChange?.Invoke(); } }
@inject ThemeState ThemeState @implements IDisposable @code { protected override void OnInitialized() => ThemeState.OnChange += StateHasChanged; public void Dispose() => ThemeState.OnChange -= StateHasChanged; }
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.
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.
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(); } }
// Scoped per circuit (Server) or per tab (WASM) builder.Services.AddScoped<CartService>();
| 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 |
| 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 |
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.
// 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
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:
@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)); } }
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.
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(); } }
InvokeAsync(StateHasChanged) via the component's dispatcher rather than calling
StateHasChanged directly, which is not thread-safe on Blazor Server.
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); }