State Management
By the end of this lesson
Share state between components without creating hidden coupling.
State is the data your interface currently depends on: which employee is selected, what the search box holds, who is signed in. Storing it is never the hard part. The hard part is deciding who owns each piece, and how the components that care find out it changed.
Blazor gives you three answers and they are not equal. Parameters, which you already know. Cascading values, for something genuinely ambient. A registered service, for state that several unrelated components need. They are listed in that order deliberately: each one reaches further than the last, and each one hides more of what a component depends on.
The advice that follows is one sentence long. Use the narrowest option that works, and move outwards only when passing values through has started to hurt for real rather than in theory.
@page "/board"
@rendermode InteractiveServer
<EmployeeFilter Term="term" OnTermChanged="ApplyTerm" />
<EmployeeList Employees="visible"
SelectedId="selectedId"
OnSelect="Select" />
@if (selected is not null)
{
<EmployeeDetail Employee="selected" />
}
@code {
private List<Employee> all = [];
private List<Employee> visible = [];
private int? selectedId;
private Employee? selected => all.FirstOrDefault(e => e.Id == selectedId);
private string term = "";
private void ApplyTerm(string value)
{
term = value;
visible = all
.Where(e => e.Name.Contains(value, StringComparison.OrdinalIgnoreCase))
.ToList();
}
private void Select(int id) => selectedId = id;
}- Three children, one owner. The board holds the term and the selection; the children receive what they need and announce what happened. Nothing else in the application knows this state exists.
- The selection is stored as an id rather than as an Employee. The list can be reloaded and the selection survives, because an id stays valid where an object reference does not.
- This costs nothing to read. Open the file and the state is in front of you, along with every component allowed to affect it. No search is needed to find out what changes what.
- It is also the only option here that a compiler helps with. Rename a parameter and every caller breaks at build time. A service or a cascading value gives you no such warning.
- The limit is visible too. Add three more levels of components between the board and the row, and every level has to accept and forward parameters it has no interest in. That is the point where the next two options earn consideration — not before.
@inherits LayoutComponentBase
@inject IPortalUserAccessor UserAccessor
<CascadingValue Value="signedInUser" IsFixed="true">
<div class="portal">
<main id="main">@Body</main>
</div>
</CascadingValue>
@code {
private PortalUser? signedInUser;
protected override async Task OnInitializedAsync() =>
signedInUser = await UserAccessor.GetCurrentAsync();
}
@* Any descendant, at any depth, without a single parameter being forwarded *@
@code {
[CascadingParameter]
public PortalUser? SignedInUser { get; set; }
private bool ShowAdminTools => SignedInUser?.IsHrAdmin is true;
}- CascadingValue supplies a value to everything rendered inside it. Descendants pick it up with [CascadingParameter] and nothing in between has to forward it.
- Matching is by type by default, which is why the property type is PortalUser and not string. Two cascading values of the same type need a Name on both ends to stay distinguishable.
- IsFixed="true" says this value will not change while the layout lives. Blazor then skips the bookkeeping that lets it notify descendants, which is measurable in a deep tree. Set it and then change the value, and descendants keep the old one with no error to tell you.
- A missing cascading value is null, not an exception. Move the component somewhere without the ancestor and it renders with no user and no complaint, so the nullable type here is doing real work.
- Keep this for what is genuinely ambient: the signed-in user, a theme, a tenant, the authentication state the next lesson uses. Once you are cascading the selected employee, the dependency has become invisible in the markup and the coupling is back — just harder to see than a parameter.
// Services/SelectionState.cs
public sealed class SelectionState
{
public int? SelectedEmployeeId { get; private set; }
public event Action? Changed;
public void Select(int? employeeId)
{
if (SelectedEmployeeId == employeeId)
{
return;
}
SelectedEmployeeId = employeeId;
Changed?.Invoke();
}
}
// Program.cs — per circuit in Blazor Server, so per user
builder.Services.AddScoped<SelectionState>();- The value has a private setter and changes through a method. One place can change it, and that place raises the notification, so the two can never drift apart.
- The guard clause is not tidiness. Raising Changed when nothing changed re-renders every subscriber for nothing, and in a tree with twenty subscribers that is where a frame budget goes.
- Changed is a plain C# event and the service references nothing from Blazor. It stays unit-testable, and rendering concerns stay in the components where they belong.
- AddScoped means one instance per circuit in Blazor Server, which is one per user per tab. A singleton here would share one person's selection with everybody signed in, which is a data-disclosure bug rather than a rendering one.
- In WebAssembly scoped and singleton are the same thing, because the scope is the application in that tab. The registration still reads as the intent, and it behaves correctly if the component is ever moved to a server render mode.
@inject SelectionState Selection
@implements IDisposable
<p role="status">
@if (Selection.SelectedEmployeeId is int id)
{
<span>Selected employee: @id</span>
}
else
{
<span>No employee selected</span>
}
</p>
@code {
protected override void OnInitialized() => Selection.Changed += OnSelectionChanged;
private void OnSelectionChanged() => InvokeAsync(StateHasChanged);
public void Dispose() => Selection.Changed -= OnSelectionChanged;
}- StateHasChanged is needed here because nothing the framework invoked caused the change. Blazor re-renders automatically after an event handler it called; a notification arriving from a service is outside that, so you ask for the render yourself.
- InvokeAsync moves the call onto the renderer's synchronisation context. If the notification came from a timer, a background service or another component's async work, calling StateHasChanged directly is a threading bug that reproduces intermittently. Wrapping it costs nothing when you are already on the right context.
- Subscribing in OnInitialized rather than in the markup or a property means it happens exactly once per component instance.
- Dispose removes the handler. Skip it and the service keeps a reference to a component that has gone, so it is never collected and is still asked to render. In Blazor Server the circuit can live for hours, so the leak grows with every navigation.
- @implements IDisposable is what makes Blazor call Dispose when the component is removed. Writing the method without the directive compiles and never runs, which is a quiet way to keep the leak.
- role="status" so the change is announced. A banner that updates silently is information a screen reader user never receives.
Summary
- Decide who owns each piece of state before deciding where to put it; most state belongs to one component and should stay there
- Parameters and events are the default, and the only option the compiler checks for you
- CascadingValue suits genuinely ambient values such as the signed-in user, and hides the dependency from the markup in exchange
- A registered state service reaches unrelated branches of the tree, at the cost of manual change notification and an invisible dependency
- State changed outside an event handler needs StateHasChanged, wrapped in InvokeAsync when it might arrive from another thread, and every subscription needs an unsubscribe in Dispose
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Think about it
Where does each piece of state belong?
Four pieces of state in the portal. The search term on the employee list. The signed-in person's name and permissions. A count of unsaved edits, shown in the header while an edit form is open. Whether one row in the list is expanded.
For each, choose a component field, a parameter from a parent, a cascading value, or a state service. Give the reason rather than the rule.
Show solution
The expanded row is a field on the row component. Nothing else needs it, nothing else should be able to change it, and lifting it to the parent would re-render the whole list on every expand.
The search term belongs to the page that shows the list, passed down to the filter component as a parameter with an event back. It is also a reasonable candidate for the query string, which the routing lesson covered — if someone would ever share a link to a filtered list, the address is the better home.
The signed-in person is the clearest cascading value. It is true everywhere, every layer might want it, and threading it through as a parameter would touch components that have no other reason to change. The next lesson shows that Blazor already cascades the authentication state for exactly this reason.
The unsaved-edit count is the one that needs a service. The header and the form are in unrelated branches of the tree — the header is in the layout, the form is inside @Body — so no common parent can pass a parameter, and cascading down from the layout only carries values in one direction. A scoped service with a Changed event is the honest answer, and it is the one case of the four where the extra machinery pays for itself.
That ratio is representative. Most state is local, some is passed, a little is ambient, and rarely something needs a service. An application where most state is in services has usually made a habit out of an exception.
Try it yourself
Find the leak, then close it
Subscribe a component to a state service's Changed event in OnInitialized and deliberately leave out the Dispose. Add a log line in the handler that includes a value unique to the component instance.
Navigate away and back ten times, then change the state once. Count the log lines. Add the Dispose and repeat.
Show solution
You get ten or eleven log lines for a single change. Every component instance you thought was gone is still subscribed, still holding whatever it holds, and still being asked to render into a tree it left. The service's event keeps a reference to each one, so garbage collection cannot help.
In Blazor Server this is worse than it first appears, because the service is scoped to the circuit rather than to a request. The count keeps climbing for the whole session, and a person who navigates around the portal all morning ends up with hundreds of dead subscribers behind one page.
With Dispose in place the count is one. The unsubscribe has to use the same method reference that was added — a lambda written twice creates two different delegates, and the removal silently does nothing. Naming the handler, as below, avoids that.
This is the strongest argument for keeping state in parameters where you can. Parameters have no subscription to forget.
@inject SelectionState Selection
@implements IDisposable
@code {
private readonly string instanceId = Guid.NewGuid().ToString("N")[..6];
protected override void OnInitialized() => Selection.Changed += OnChanged;
private void OnChanged()
{
Logger.LogInformation("Instance {Instance} was notified", instanceId);
InvokeAsync(StateHasChanged);
}
// Removing a named handler works. Removing a second lambda does not.
public void Dispose() => Selection.Changed -= OnChanged;
}Saved in this browser only.