Blog

Blazor state management: every option and when to use it

Blazor
State Management
.NET

Component state, cascading values, the URL, browser storage, state containers, Fluxor and signals: how to manage state in Blazor and what each costs.

Blazor state management: every option and when to use it

Every Blazor app reaches the point where two components need the same data and passing parameters down three levels stops being reasonable. Blazor gives you several ways to solve that, and they are not interchangeable: some survive a page reload, some only live in memory, and some re-render half the page every time a field changes.

This is the map I use to decide. I start with the simplest option that works and only move down the list when I hit its limits.

1. Component state and parameters

The first level is a field in the component and [Parameter] properties for the children. A child that needs to change something raises an EventCallback and the parent owns the value.

Counter.razor
<button @onclick="Increment">Clicked @Count times</button>
@code {
[Parameter] public int Count { get; set; }
[Parameter] public EventCallback<int> CountChanged { get; set; }
private Task Increment() => CountChanged.InvokeAsync(Count + 1);
}

With @bind-Count="count" in the parent this becomes two-way binding. It is enough for a form, a modal or a list with filters. It stops being enough when the data has to cross pages or when you are passing the same parameter through components that don’t use it.

2. Cascading values

CascadingValue makes a value available to every descendant without passing it explicitly. It fits data that many components read and few change: the current user, the theme, the tenant.

<CascadingValue Value="currentTheme">
<Router AppAssembly="typeof(App).Assembly" />
</CascadingValue>
[CascadingParameter] public Theme? Theme { get; set; }

Since .NET 8 you can also register root-level cascading values in DI with builder.Services.AddCascadingValue(...), which avoids wrapping the router. The catch is that every component receiving the value re-renders when it changes, so I don’t use it for data that changes often.

3. The URL

Filters, the current page of a table or the selected tab belong in the URL. They survive a reload, the user can share the link and the browser’s back button works.

@page "/products"
@code {
[SupplyParameterFromQuery] public string? Category { get; set; }
[SupplyParameterFromQuery] public int Page { get; set; } = 1;
}

To change it, navigate with NavigationManager.GetUriWithQueryParameter("page", 2). It is the most underrated option on this list.

4. Browser storage

For things that should survive closing the tab, like a draft or user preferences, you need localStorage or sessionStorage. In Blazor Server and in interactive server components, ProtectedLocalStorage and ProtectedSessionStorage encrypt the data with Data Protection. In WebAssembly you go through JS interop or a library like Blazored.LocalStorage.

Two things to keep in mind: storage is only available once the component is interactive (after OnAfterRenderAsync, not during prerendering), and anything in the browser can be read by the user, so it is not a place for anything sensitive.

5. The state container

This is Microsoft’s recommended pattern for sharing in-memory state between components and pages. A class registered as a scoped service holds the state and raises an event when it changes.

CartState.cs
public class CartState
{
private readonly List<CartItem> _items = new();
public IReadOnlyList<CartItem> Items => _items;
public event Action? OnChange;
public void Add(CartItem item)
{
_items.Add(item);
OnChange?.Invoke();
}
}
// Program.cs
builder.Services.AddScoped<CartState>();
@inject CartState Cart
@implements IDisposable
<span>@Cart.Items.Count items</span>
@code {
protected override void OnInitialized() => Cart.OnChange += StateHasChanged;
public void Dispose() => Cart.OnChange -= StateHasChanged;
}

“Scoped” doesn’t mean the same thing in every render mode. In WebAssembly there is one scope per browser tab, so it behaves like a singleton for that user. In Blazor Server the scope is the circuit: if the connection drops and a new circuit is created, the state is gone unless you persist it somewhere.

I have used this pattern in several projects and it works, but it has two costs that grow with the app. Every component that listens has to subscribe in OnInitialized and unsubscribe in Dispose, and if you forget the second one you have a memory leak. And any change to the container re-renders every subscribed component, even if it only displays a field that didn’t change.

6. Persisting prerendered state

If your app prerenders on the server, the component runs twice: once to generate the HTML and again when it becomes interactive. Without doing anything, the data you loaded in the first pass is loaded again in the second and the user sees a flicker.

PersistentComponentState lets you serialize the state in the first render and restore it in the second. In .NET 10 it can be done declaratively with the [PersistentState] attribute on a property:

@code {
[PersistentState] public List<Product>? Products { get; set; }
protected override async Task OnInitializedAsync()
{
Products ??= await ProductService.GetAllAsync();
}
}

It doesn’t replace any of the previous options. It solves a specific problem of prerendering and nothing else.

7. Fluxor: Redux in Blazor

Fluxor brings the Flux/Redux pattern to .NET: immutable state, actions that describe what happened, reducers that compute the new state and effects for side effects like HTTP calls.

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

Its strengths are predictability and tooling: every change goes through an action, you can log them and the Redux DevTools integration lets you see the history. The price is ceremony. A new field can mean an action, a reducer and sometimes an effect, spread across several files. It pays off in large apps with a team that already thinks in Redux. In a small app it feels like a lot of code for little.

8. Signals

The last option is the one I ended up building myself. After writing the same subscribe/unsubscribe code in several projects with the state container, I wrote BlazorSignalStore, a library based on signals, the same idea Angular, Solid or Preact use.

Each piece of state is a Signal<T>, and derived values are Computed<T> that are recalculated only when their dependencies change:

public class CartStore : StoreBase
{
public Signal<int> Quantity { get; } = new(1);
public Signal<decimal> UnitPrice { get; } = new(10m);
public Computed<decimal> Total { get; }
public CartStore()
{
Total = new Computed<decimal>(() => Quantity.Value * UnitPrice.Value, Quantity, UnitPrice);
}
}
// Program.cs
builder.Services.AddSignalStore<CartStore>();

The component subscribes to the signals it uses and there is no IDisposable to write:

@inject CartStore Store
<p>Total: @total()</p>
@code {
private Func<decimal>? total;
protected override void OnInitialized() => total = this.useSignal(Store.Total);
}

In the demo I compare it with the state container on a nine-field form, and the signals version does about 50% fewer re-renders, because changing a field only affects what depends on it. The full comparison is here.

How I choose

NeedOption
State of one component or a parent and its childrenFields, parameters and EventCallback
Data many components read and that rarely changesCascading values
Filters, pagination, anything worth sharing in a linkThe URL
Survive closing the tabBrowser storage
Share in-memory state between pagesState container or signals
Avoid loading data twice with prerendering[PersistentState]
Large app, team used to Redux, need a change historyFluxor
Forms or screens with a lot of interdependent stateSignals

Most apps need two or three of these at once. In mine, the URL holds filters, storage holds preferences and a store holds the rest. The mistake I see most often is jumping straight to the most complex option for a problem that a parameter or the URL would solve.


Comments

No comments yet. Be the first!

Leave a Comment

Your name and comment will be published once approved. More in the privacy policy.