Blog

The Blazor state container pattern, with a complete example

Blazor
State Management
.NET

How to build a state container in Blazor: scoped vs singleton, notifying from background threads, avoiding memory leaks and when to move on.

The Blazor state container pattern, with a complete example

The state container is the pattern Microsoft recommends for sharing in-memory state between Blazor components that are not parent and child. The idea fits in one sentence: a service registered in DI holds the state and raises an event when it changes, and the components that care subscribe to that event.

The basic version is ten lines. The problems show up in the details, so this post goes through the version I would put in production and the mistakes that are easy to make with it.

The minimal version

public class CounterState
{
public int Count { get; private set; }
public event Action? OnChange;
public void Increment()
{
Count++;
OnChange?.Invoke();
}
}
Program.cs
builder.Services.AddScoped<CounterState>();
@inject CounterState State
@implements IDisposable
<p>@State.Count</p>
<button @onclick="State.Increment">+1</button>
@code {
protected override void OnInitialized() => State.OnChange += StateHasChanged;
public void Dispose() => State.OnChange -= StateHasChanged;
}

Any component that injects CounterState and subscribes sees the same value. The setter is private so the only way to change the state is through the container’s methods, which is what guarantees that OnChange is always raised.

Scoped, never singleton (in Blazor Server)

The lifetime decides who shares the state, and it changes with the render mode.

In WebAssembly the app runs in the browser and there is one DI container per tab. Scoped and singleton end up behaving the same: one instance per user and per tab.

In Blazor Server, and in interactive server components, scoped means one instance per circuit, that is, per browser connection. A singleton is shared by every user connected to the server. If you register a cart as a singleton, all your users share the same cart. It is the most serious bug this pattern allows, and it compiles and works perfectly while you test alone.

Also in Blazor Server, the circuit can die: the user closes the laptop, the connection drops for too long, the server restarts. When a new circuit is created the scoped services are new and the state is empty. If that state matters, persist it in the database or in browser storage and rebuild it when the container starts.

Notifying from another thread

If the state changes in response to a button click, everything happens in the renderer’s synchronization context and StateHasChanged works. If it changes from a timer, a SignalR message or a Task.Run, in Blazor Server you get an exception saying the current thread is not associated with the Dispatcher.

The fix goes in the component, not in the container:

@code {
protected override void OnInitialized() => State.OnChange += OnStateChanged;
private void OnStateChanged() => InvokeAsync(StateHasChanged);
public void Dispose() => State.OnChange -= OnStateChanged;
}

InvokeAsync queues the render in the right context. I use it always, even when today all changes come from the UI, because the day someone adds a timer it will fail in production and not on their machine.

Immutable state

With mutable properties it is easy for a component to do State.Items.Add(x) without going through the container and without raising OnChange. Exposing a record and replacing it in each change closes that door:

public record CartSnapshot(IReadOnlyList<CartItem> Items, decimal Total);
public class CartState
{
public CartSnapshot Current { get; private set; } = new([], 0m);
public event Action? OnChange;
public void Add(CartItem item)
{
var items = Current.Items.Append(item).ToList();
Current = new CartSnapshot(items, items.Sum(i => i.Price * i.Quantity));
OnChange?.Invoke();
}
}

The total is computed when the state changes, not every time a component renders it, and nobody can modify the list from outside.

The memory leak you don’t see

The container lives longer than the components. If a component subscribes and doesn’t unsubscribe, the container’s event keeps a reference to it and it is never collected. In Blazor Server, where the circuit can last hours, this adds up: every navigation to the page leaves another instance hanging, and every change in the state calls StateHasChanged on components that are no longer on screen.

The rule is simple: every += in OnInitialized has its -= in Dispose, and the component declares @implements IDisposable. A base component that does it for you helps:

public abstract class StateComponent<TState> : ComponentBase, IDisposable
where TState : class, INotifyStateChanged
{
[Inject] protected TState State { get; set; } = default!;
protected override void OnInitialized() => State.OnChange += OnStateChanged;
private void OnStateChanged() => InvokeAsync(StateHasChanged);
public virtual void Dispose() => State.OnChange -= OnStateChanged;
}
public interface INotifyStateChanged
{
event Action? OnChange;
}

CartState only needs to declare : INotifyStateChanged, and the page becomes @inherits StateComponent<CartState> with no subscription code.

Testing it

Because the container is a plain class with no Blazor dependencies, it can be tested with xUnit without bUnit:

[Fact]
public void Add_updates_total_and_notifies()
{
var state = new CartState();
var notified = false;
state.OnChange += () => notified = true;
state.Add(new CartItem("Book", 20m, 2));
Assert.Equal(40m, state.Current.Total);
Assert.True(notified);
}

This is one of the advantages over keeping the state inside components.

When it stops being enough

The state container has one limit that no trick fixes: there is a single event for the whole container. If a page shows the cart total in the header, the list in the body and a counter in a side panel, adding an item re-renders all three, even if one of them shows something that didn’t change. With one container per feature that is fine. With a form of twenty fields in a single container, every keystroke re-renders the whole form.

At that point there are two ways out. You can split the container into smaller ones, or move to an approach with finer-grained notifications. That is what led me to write BlazorSignalStore: each property is a signal and only the components that read it re-render. If you want to see all the options side by side, I compare them in Blazor state management: every option and when to use it.


Comments

No comments yet. Be the first!

Leave a Comment

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