DEV Community

Cover image for State Management in Blazor: Complete Guide to Building Scalable Apps
Lucy Muturi for Syncfusion, Inc.

Posted on • Originally published at syncfusion.com on

State Management in Blazor: Complete Guide to Building Scalable Apps

TL;DR: Struggling with lost data after refreshes, messy component communication, or unexpected state leaks in your Blazor app? The problem usually isn’t Blazor; it’s how state is managed. This guide breaks down the most effective state management patterns in Blazor, from component state and dependency injection to browser storage and persistent state, helping you build scalable, reliable apps that behave predictably across every user interaction.

Your Blazor app works perfectly… until it suddenly doesn’t. Everything feels stable, until:

  • You refresh, and your form data disappears.
  • A small change breaks the state across components.
  • Or worse, one user sees another user’s data.

This isn’t rare. It’s one of the most common problems in real-world Blazor apps and it makes your app feel unreliable fast.

Here’s the hard truth: your app isn’t broken. Your state management is.

Blazor gives you a lot of flexibility, but without clear boundaries for where state should live, that flexibility quickly turns into confusion.

Fix that, and everything changes:

  • No more lost data on refresh,
  • No more property drilling chaos,
  • No more cross-user bugs, and
  • No more guessing where the state belongs.

In this guide, you’ll learn the practical state management patterns in Blazor (.NET 10 / ASP.NET Core 10), with real examples, so your apps stay stable, predictable, and easy to scale.

What is state management?

The web is inherently stateless. Anything you don’t deliberately store is gone when:

  • User refreshes (F5),
  • Tab closes,
  • User navigates away,
  • Server circuit reconnects (Blazor Server),
  • App switches from prerendered HTML to interactive mode (Blazor Web App).

The state management is simply about deciding where data should live so it persists across these events without leaking between users or making the app difficult to maintain.

How does state management work in Blazor?

Blazor gives you the same component model across three hosting approaches, but where and how long the state lives change dramatically.

Blazor WebAssembly (Pure client-side):

The entire .NET runtime and your app run inside the browser. Here, the state lives entirely in the browser’s memory:

  • Values of fields and properties in component instances.
  • The component hierarchy and its latest render output.
  • Data is held in DI services (usually registered as singletons, since the app runs in the browser).
  • Values set through JavaScript interop and browser storage (localStorage or sessionStorage).

Blazor Server (Server-side with SignalR):

Your components and logic execute on the server. State is tied to a live SignalR circuit:

  • Component instances and their in-memory fields are created on the server.
  • Scoped DI services that live for the duration of the user’s circuit.
  • Any server-side object that survive only while the connection is active.

Blazor Web App (Unified .NET 8+ model):

You get static server-side rendering (SSR) + optional interactivity (Server or WebAssembly per component).

  • State can flow seamlessly between server prerendering and client using PersistentComponentState, with Protected Browser Storage for secure persistence and automatic hydration across render modes.

In short, effective state management in Blazor means selecting the right technique based on state lifetime, scope, and hosting model, so user data remains consistent and reliable.

Which state management tool should I use?

The following are the state management types you can implement in Blazor.

Use this as a first pass:

  • Bookmarkable/shareable state: URL route/query parameters.
  • UI-only state inside one component: Local component fields.
  • Direct parent ↔ child coordination: Parameters + EventCallback.
  • Deep subtree configuration/context: Cascading values/parameters.
  • Cross-page or unrelated components: DI service (scoped/singleton carefully).
  • Reactive shared state: State container with change notifications.
  • Prerender → interactive continuity: [PersistentState] or PersistentComponentState.
  • Secure persistence in browser (Server only): ProtectedLocalStorage / ProtectedSessionStorage.

URL/query string parameters

For a state that needs to be bookmarkable, shareable via URL, or directly controls what is displayed on a page, encoding it in the URL is a robust approach.

Blazor’s routing mechanism allows you to define parameters directly in the URL path or extract them from the query string.

Use cases:

  • Product ID: /products/123
  • Category filter: /products?category=electronics
  • Search terms: /search?q=blazor+state
  • Pagination state (/items?page=2&pageSize=10).

Pros:

  • State is part of the URL, making it bookmarkable and shareable.
  • Naturally handles browser navigation (back/forward buttons).
  • Stateless from the server’s perspective (good for SEO and caching if server-side rendered).

Cons:

  • Limited to simple data types (strings, numbers, dates).
  • Not suitable for large or complex objects.
  • Can make URLs long and less readable if too many parameters are used.

Refer to the GitHub example demonstrating URL-based state management using route parameters and query strings.

Here, the {Category} route parameter, combined with [SupplyParameterFromQuery(Name = “searchTerm”)], automatically binds values from both the URL path and query string into component properties. Using the NavManager.NavigateTo() method, along with manual URI construction in ApplySearch(), ensures that any filter change updates the browser URL.

As a result, refreshing the page, bookmarking the link, or using browser navigation restores the exact filtered view without relying on additional storage, services, or state containers.

Local component state

The local component state is the simplest form of state management. Any private field or property in a @code block is local state, where state is contained entirely within a single Blazor component. This is fast, zero-overhead, and perfect for UI-only concerns (toggle flags, temporary form drafts, and animation states).

Use cases:

  • State for UI elements like a toggle button’s IsActive status.
  • Temporary input values in a form that haven’t been submitted yet.
  • Any state that is only relevant to the component itself and doesn’t need to be shared.

Pros:

  • Extremely simple and straightforward.
  • Highly encapsulated and isolated, reducing side effects.

Cons:

  • Cannot be directly accessed or shared by other components.

For more details, refer to the code example on the GitHub repository.

Improvement tip: If the same state needs to be shared across sibling components, this approach no longer works; that’s your cue to move to a higher-level state management pattern.

Parent-child communication (Parameters & EventCallbacks)

This is the standard way to manage state flow between directly related components in a hierarchical structure.

  • Parameters ([Parameter]): Used by a parent component to pass data to a child component.
  • EventCallbacks ([Parameter] EventCallback): Used by a child component to send notifications or data to its parent.

Use cases:

  • Displaying a list of items where each item is rendered by a child component.
  • A child component triggering an action in its parent (e.g., a “Save” button in a child form).
  • Any scenario where components have a clear parent-child relationship and need to exchange data.

Pros:

  • Clear and explicit data flow, easy to trace.
  • Strongly typed, enhancing code safety.
  • Supports two-way binding using Parameter and EventCallback pairs (e.g., Value and ValueChanged).

Cons:

  • Prop drilling: If data needs to go through many intermediate components that don’t use it, it can lead to verbose and difficult-to-maintain code.

Find the code example on GitHub and see the output image below for a better understanding.

Parent and child components communication in Blazor state management


Sharing state down the tree: Cascading parameters and values

The cascading parameters provide an elegant solution for passing data down a component hierarchy without the need for explicit parameter declarations at every level.

A component wraps a portion of the UI and makes a value available to all components within that subtree. The child components consume this value using [CascadingParameter].

For dynamic updates, use the CascadingValueSource(newer, notification-capable pattern).

The following component hierarchy diagram with arrows showcases the cascading parameter flow from root to leaves, highlighting reduced prop drilling.

Cascading parameter and values flowchart


Use cases:

  • Providing color schemes, fonts, or UI densities.
  • Making the current user’s identity or permissions available.
  • Sharing user preferences across many components.
  • Avoiding prop drilling for common, deeply nested data.

Pros:

  • Eliminates prop drilling for deeply nested components.
  • Cleaner component signatures as parameters aren’t explicitly listed.

Cons:

  • Less explicit data flow; it can be harder to see where a value originates from.
  • Can be misused for non-hierarchical data, leading to less maintainable code.

Also, check out the demo on the GitHub repository and see the output below.

Cascading parameters and value type state management in Blazor output


Global state with dependency injection services

When a state needs to be shared across unrelated components or pages, using a dependency injection service is the most practical approach. A shared service allows the state to persist for the lifetime of a user session (Blazor Server) or browser tab (Blazor WebAssembly).

This is implemented by registering a plain C# class in Blazor’s dependency injection container. The service holds the shared data and typically exposes methods to update it. If needed, it can also notify components about changes.

Registration scopes:

Choosing the correct lifetime is critical:

  • AddScoped():
    • Blazor Server: A new instance per user circuit/session. Ideal for per-user state (e.g., shopping cart, user preferences).
    • Blazor WebAssembly: A new instance per browser tab. Ideal for per-tab state.
  • AddSingleton():
    • Blazor Server: A single instance for the entire app. Use with caution, as all users will share the same instance.
    • Blazor WebAssembly: A single instance for the entire app running in the browser tab.

Use cases:

  • Shopping cart accessible from product pages, header, and checkout.
  • User profile data that is shared across various authenticated pages.
  • Global notifications (toasts, alerts).
  • Any complex state that doesn’t fit a parent-child relationship.

Pros:

  • Centralized state management, keeping components lean.
  • Decoupled components from state implementation details.
  • Highly testable.
  • Supports various scopes to match state longevity needs.

Cons:

  • Requires manual implementation of change notification (e.g., event, Action delegates, StateHasChanged()).
  • Can become complex for very large apps without additional patterns (such as implementing a more structured, centralized state management solution like the Flux or Redux patterns using libraries like Fluxor, which enforce a unidirectional data flow and make state changes more predictable and manageable).

Example:

CounterState.cs

public class CounterState
{
    public int Count { get; private set; }

    public void Increment() => Count++;
    public void Reset() => Count = 0;
}
Enter fullscreen mode Exit fullscreen mode

The registration differs by hosting model, see the following code example for more details.

Program.cs


// Blazor WebAssembly – Program.cs
builder.Services.AddSingleton<CounterState>();

// Blazor Server – Program.cs (.NET 6+)
builder.Services.AddScoped<CounterState>();
Enter fullscreen mode Exit fullscreen mode

Scoped services in Blazor Server live per user circuit, while singletons in WebAssembly live for the browser tab. Using the wrong scope can lead to memory issues or shared data across users.

Note: In a Blazor Web App, when the CounterState class is added to the WebAssembly project, and you are using server-side prerendering, you need to register CounterState in the Server project as well, along with the WebAssembly project. Also, you can avoid prerendering at the server side by setting @rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false)) and avoid registering the CounterState at the Server project.

Consumption:

ComponentUsingService.razor


@inject CounterState CounterState

<p>Count: @CounterState.Count</p>
<button @onclick="() => CounterState.Increment()">+</button>

Enter fullscreen mode Exit fullscreen mode

Reactive state containers

Plain services like the above example don’t notify consumers of changes. A lightweight container fixes that.

ReactiveStateContainer.cs


public class ReactiveStateContainer
{
    private string? _savedString;

    public string Property
    {
        get => _savedString ?? string.Empty;
        set
        {
            _savedString = value;
            NotifyStateChanged();
        }
    }

    public event Action? OnChange;

    private void NotifyStateChanged() => OnChange?.Invoke();
}
Enter fullscreen mode Exit fullscreen mode

Component usage (important: always unsubscribe):

StateContainer.razor


@page "/reactive-state-container"
@rendermode InteractiveWebAssembly
@implements IDisposable
@inject ReactiveStateContainer ReactiveStateContainer

<h2>Reactive State Container Demo</h2>
<p>Property: <strong>@ReactiveStateContainer.Property</strong></p>
<button class="btn btn-info" @onclick="UpdateValue">Update from Parent</button>

<NestedComponent />

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

    private void UpdateValue()
    {
        ReactiveStateContainer.Property = $"Updated at {DateTime.Now}";
    }

    public void Dispose()
    {
        ReactiveStateContainer.OnChange -= StateHasChanged; // Prevent memory leak
    }
}
Enter fullscreen mode Exit fullscreen mode

NestedComponent.razor:

NestedComponent.razor


@implements IDisposable
@inject ReactiveStateContainer ReactiveStateContainer

<div class="border p-4 mt-4 bg-light shadow-sm rounded"
     style="max-width: 580px;">
    <p>
        <strong>Nested component:</strong>
        Property = <em>@ReactiveStateContainer.Property</em>
    </p>

    <button class="btn btn-secondary" @onclick="UpdateFromNested">
        Update from Nested
    </button>
</div>

@code {
    protected override void OnInitialized()
    {
        // Subscribe so nested component re-renders on changes
        ReactiveStateContainer.OnChange += StateHasChanged;
    }

    private void UpdateFromNested()
    {
        ReactiveStateContainer.Property = $"Updated by Nested at {DateTime.Now:HH:mm:ss}";
    }

    public void Dispose()
    {
        // Unsubscribe on disposal
        ReactiveStateContainer.OnChange -= StateHasChanged;
    }
}
Enter fullscreen mode Exit fullscreen mode

After executing the above code examples, the output will resemble the following image.

Reactive state container demo output


After clicking the nested component, the property is updated with a formatted date value.

Sequence diagram of reactive state container workflow


Prerendered state persistence

The prerendered state persistence in Blazor means saving the UI state created during server prerendering so the client can continue seamlessly without re-running components.

  • In Blazor Server, this prevents the UI from resetting when the SignalR connection is established, giving a smooth transition from prerendered HTML to interactive components.
  • In Blazor WebAssembly, it lets the WASM runtime pick up the prerendered state immediately after downloading, avoiding flicker and improving perceived load performance.

You can persist the prerendered state in the following ways:

  • [PersistentState] attribute (Declarative)
  • PersistentComponentState service (Manual/imperative)
  • Custom serializer PersistentComponentStateSerializer

Let’s explore them in detail!

[PersistentState] attribute (Declarative)

Properties that are public, annotated with the [PersistentState] attribute, are serialized during prerendering. This is the simplest declarative approach and recommended for most cases.

Use cases:

  • Simple read-only or slowly changing data (weather forecasts, user preferences, catch lists).
  • Avoiding UI flicker on first load / F5.
  • Data that should survive prerender → interactive handover.

Pros:

  • Extremely clean (one line).
  • Automatic registration, cleanup, and restoration.
  • New in .NET 10 – officially recommended way.

Cons:

  • Only works on public properties.
  • Default JSON serialization (no easy way to encrypt property data without a custom serializer).
  • Limited control over timing.

In the GitHub example code, the Forecasts property is declared as public and marked with the [PersistentState] attribute, which will serialize all the four properties of WeatherForecast items.

  • If your data is mostly read‑only and doesn’t change often, you can still enable AllowUpdates = true on the [PersistentState] attribute so that any occasional updates get stored during enhanced navigation.
  • This is helpful for scenarios where the data is cached and expensive to retrieve, allowing the app to keep the latest snapshot without refetching or rerunning initialization logic.

To prevent the state from being restored when the app is prerendered, set RestoreBehavior to SkipInitialValue. If you want the state to refresh after a reconnection instead of using the last saved snapshot, set RestoreBehavior to SkipLastSnapshot.

C#


[PersistentState(AllowUpdates = true)]
public WeatherForecast[]? Forecasts { get; set; }

[PersistentState(RestoreBehavior = RestoreBehavior.SkipInitialValue)]
public string NoPrerenderedData { get; set; }

[PersistentState(RestoreBehavior = RestoreBehavior.SkipLastSnapshot)]
public int CounterNotRestoredOnReconnect { get; set; }

Enter fullscreen mode Exit fullscreen mode

To persist private property or a field, you need to use the PersistentComponentState class manually. Let’s see how to use it.

Read the full blog post on the Syncfusion Website

Top comments (0)