Your Blazor State Might Belong in the URL
When building a Blazor component, storing UI state directly inside class fields is the default instinct:
private string? searchText;
private string? status;
private string sortColumn = "Name";
private int page = 1;
There’s nothing inherently broken about this. But think about the user experience.
A user spends three minutes filtering down a customer list: they search for "Microsoft", filter by Active, sort by LastContact, and jump to page 3. Their screen shows a precise slice of data. Yet, their browser address bar still stubbornly reads:
/customers
If they refresh, their work is wiped out. If they bookmark the page, they lose the context. If they drop that link into Teams or Slack for a colleague, the recipient lands on page 1 of an unfiltered list.
Compare that to:
/customers?search=Microsoft&status=Active&sort=LastContact&page=3
By shifting page-level view parameters into query strings, the URL becomes a deterministic blueprint of what the user is looking at. It’s a minor implementation change that immediately makes your application more resilient and usable.
The Core Rule: View-Defining State Belongs in the URL
Blazor makes component-bound state trivial to wire up:
<input @bind="SearchText" />
<select @bind="Status">
<option value="">All</option>
<option value="Active">Active</option>
<option value="Inactive">Inactive</option>
</select>
private string? SearchText { get; set; }
private string? Status { get; set; }
When you bind inputs directly to isolated component properties, the browser remains completely blind to those choices. A solid rule of thumb to evaluate your state:
If changing a piece of UI state fundamentally alters what data the user considers "the page," put it in the URL.
Decoupling view state from isolated component fields unlocks several immediate production benefits:
Bookmarkable & Shareable Views: Sending a colleague
/customers?status=Inactive&state=AZacts as a zero-code "saved view." You get feature parity with custom saved queries without building a database schema for them.Refresh Protection: Full page reloads don't destroy the active dataset context because the browser re-sends the exact query arguments on request.
Native History Navigation: Using navigation events to drive state changes means the browser's Back and Forward buttons actually work as expected, stepping through previous filter states instead of kicking the user back to the dashboard.
Frictionless Support & Debugging: Bug reports shift from vague descriptions ("The customer grid isn't showing my data") to explicit, reproducible paths ("This exact link returns no rows:
/customers?status=Active&sort=LastContact&page=4").
Harnessing [SupplyParameterFromQuery] in .NET 8+
You don't need to write manual parsing routines around NavigationManager.Uri. Modern Blazor includes the [SupplyParameterFromQuery] attribute to automatically bind query keys to properties on route targets:
@page "/customers"
<PageTitle>Customers</PageTitle>
<h1>Customers</h1>
<p>Search: @Search</p>
<p>Status: @Status</p>
<p>Page: @Page</p>
@code {
[SupplyParameterFromQuery]
private string? Search { get; set; }
[SupplyParameterFromQuery]
private string? Status { get; set; }
[SupplyParameterFromQuery]
private int Page { get; set; } = 1;
}
Navigating to /customers?search=Microsoft&status=Active&page=3 automatically maps those query values into your component properties. As of .NET 8, [SupplyParameterFromQuery] supports private setters/properties, along with primitive types, Guid, DateTime, DateOnly, and arrays.
A Complete Working Implementation
Let's walk through a realistic, fully wired implementation using a basic record and repository.
1. Data Model and Service Setup
public sealed record Customer(
int Id,
string Name,
string State,
string Status,
DateTime LastContact);
public sealed class CustomerService
{
private readonly List<Customer> customers =
[
new(1, "Microsoft", "WA", "Active", new(2026, 7, 20)),
new(2, "Contoso", "AZ", "Active", new(2026, 7, 12)),
new(3, "Adventure Works", "CA", "Inactive", new(2026, 6, 25)),
new(4, "Fabrikam", "AZ", "Active", new(2026, 7, 30)),
new(5, "Northwind", "OR", "Inactive", new(2026, 6, 15)),
new(6, "Tailspin Toys", "AZ", "Active", new(2026, 8, 1)),
new(7, "Woodgrove Bank", "WA", "Active", new(2026, 7, 18)),
new(8, "Alpine Ski House", "CO", "Inactive", new(2026, 5, 10))
];
public IReadOnlyList<Customer> GetCustomers() => customers;
}
Register the service in your program setup:
builder.Services.AddSingleton<CustomerService>();
2. Linking URL State to Execution Rules
Instead of mutating localized lists on event triggers, write your data retrieval logic to derive directly from incoming parameter properties:
private IEnumerable<Customer> FilteredCustomers
{
get
{
IEnumerable<Customer> query = CustomerService.GetCustomers();
if (!string.IsNullOrWhiteSpace(Search))
{
query = query.Where(c => c.Name.Contains(Search, StringComparison.OrdinalIgnoreCase));
}
if (!string.IsNullOrWhiteSpace(Status))
{
query = query.Where(c => c.Status.Equals(Status, StringComparison.OrdinalIgnoreCase));
}
if (!string.IsNullOrWhiteSpace(State))
{
query = query.Where(c => c.State.Equals(State, StringComparison.OrdinalIgnoreCase));
}
return Sort switch
{
"Name" => query.OrderBy(c => c.Name),
"LastContact" => query.OrderByDescending(c => c.LastContact),
_ => query.OrderBy(c => c.Id)
};
}
}
3. Mutating the URL on User Interaction
Reading query parameters is only half the equation. When a user changes a filter, you must push that change back up to the browser's address bar using NavigationManager.
Avoid manual string concatenation. Use built-in helpers like GetUriWithQueryParameters to safely update parameters or clear them by passing null:
@inject NavigationManager Navigation
@code {
private void NavigateWithParameters(IReadOnlyDictionary<string, object?> parameters)
{
var uri = Navigation.GetUriWithQueryParameters(parameters);
Navigation.NavigateTo(uri);
}
}
Why Reset Paging on Filter Changes?
A common bug in grid components occurs when a user is sitting on Page 7 and changes their status filter to Inactive. If there are only two pages of inactive results, sitting on Page 7 yields an empty UI.
Updating state via query parameters lets you easily bundle mutations—such as resetting the page to 1 whenever a search or filter changes:
private void StatusChanged(ChangeEventArgs args)
{
var status = args.Value?.ToString();
NavigateWithParameters(new Dictionary<string, object?>
{
["status"] = string.IsNullOrWhiteSpace(status) ? null : status,
["page"] = 1 // Reset pagination safely on state change
});
}
4. Handling Input Textboxes & OnParametersSet
For text inputs, binding directly to a query parameter on every keystroke can pollute browser history or trigger excessive re-renders. Instead, isolate the text input state temporarily, then push to the URL when the user explicitly clicks Search or pauses typing:
<input id="customerSearch" class="form-control" @bind="searchInput" />
<button class="btn btn-primary mt-2" @onclick="ApplySearch">Search</button>
@code {
private string? searchInput;
// React properly when navigation updates incoming parameters
protected override void OnParametersSet()
{
searchInput = Search;
}
private void ApplySearch()
{
NavigateWithParameters(new Dictionary<string, object?>
{
["search"] = string.IsNullOrWhiteSpace(searchInput) ? null : searchInput,
["page"] = 1
});
}
}
Note: Always sync your local input fields inside OnParametersSet(), not OnInitialized(). A component instance often remains alive in memory while the routing engine updates its parameters during active navigation.
Putting It All Together
Here is the complete, self-contained Razor page:
@page "/customers"
@inject CustomerService CustomerService
@inject NavigationManager Navigation
<PageTitle>Customers</PageTitle>
<h1>Customers</h1>
<div class="row g-3 mb-4">
<div class="col-md-4">
<label for="customerSearch" class="form-label">Search</label>
<input id="customerSearch" class="form-control" @bind="searchInput" />
<button class="btn btn-primary mt-2" @onclick="ApplySearch">Search</button>
</div>
<div class="col-md-4">
<label for="statusFilter" class="form-label">Status</label>
<select id="statusFilter" class="form-select" value="@Status" @onchange="StatusChanged">
<option value="">All</option>
<option value="Active">Active</option>
<option value="Inactive">Inactive</option>
</select>
</div>
</div>
<table class="table">
<thead>
<tr>
<th>
<button class="btn btn-link p-0" @onclick='() => ChangeSort("Name")'>Name</button>
</th>
<th>State</th>
<th>Status</th>
<th>
<button class="btn btn-link p-0" @onclick='() => ChangeSort("LastContact")'>Last Contact</button>
</th>
</tr>
</thead>
<tbody>
@foreach (var customer in PagedCustomers)
{
<tr @key="customer.Id">
<td>@customer.Name</td>
<td>@customer.State</td>
<td>@customer.Status</td>
<td>@customer.LastContact.ToShortDateString()</td>
</tr>
}
</tbody>
</table>
<nav aria-label="Customer results pages">
<button class="btn btn-outline-primary" disabled="@(CurrentPage <= 1)" @onclick="PreviousPage">Previous</button>
<span class="mx-3">Page @CurrentPage</span>
<button class="btn btn-outline-primary" @onclick="NextPage">Next</button>
</nav>
@code {
private const int PageSize = 3;
private string? searchInput;
[SupplyParameterFromQuery] private string? Search { get; set; }
[SupplyParameterFromQuery] private string? Status { get; set; }
[SupplyParameterFromQuery] private string? State { get; set; }
[SupplyParameterFromQuery] private string? Sort { get; set; }
[SupplyParameterFromQuery] private int Page { get; set; } = 1;
private int CurrentPage => Page < 1 ? 1 : Page;
protected override void OnParametersSet()
{
searchInput = Search;
}
private IEnumerable<Customer> FilteredCustomers
{
get
{
IEnumerable<Customer> query = CustomerService.GetCustomers();
if (!string.IsNullOrWhiteSpace(Search))
{
query = query.Where(c => c.Name.Contains(Search, StringComparison.OrdinalIgnoreCase));
}
if (!string.IsNullOrWhiteSpace(Status))
{
query = query.Where(c => c.Status.Equals(Status, StringComparison.OrdinalIgnoreCase));
}
if (!string.IsNullOrWhiteSpace(State))
{
query = query.Where(c => c.State.Equals(State, StringComparison.OrdinalIgnoreCase));
}
return Sort switch
{
"Name" => query.OrderBy(c => c.Name),
"LastContact" => query.OrderByDescending(c => c.LastContact),
_ => query.OrderBy(c => c.Id)
};
}
}
private IEnumerable<Customer> PagedCustomers =>
FilteredCustomers
.Skip((CurrentPage - 1) * PageSize)
.Take(PageSize);
private void ApplySearch()
{
NavigateWithParameters(new Dictionary<string, object?>
{
["search"] = string.IsNullOrWhiteSpace(searchInput) ? null : searchInput,
["page"] = 1
});
}
private void StatusChanged(ChangeEventArgs args)
{
var status = args.Value?.ToString();
NavigateWithParameters(new Dictionary<string, object?>
{
["status"] = string.IsNullOrWhiteSpace(status) ? null : status,
["page"] = 1
});
}
private void ChangeSort(string sort)
{
NavigateWithParameters(new Dictionary<string, object?>
{
["sort"] = sort,
["page"] = 1
});
}
private void PreviousPage()
{
if (CurrentPage <= 1) return;
NavigateWithParameters(new Dictionary<string, object?> { ["page"] = CurrentPage - 1 });
}
private void NextPage()
{
NavigateWithParameters(new Dictionary<string, object?> { ["page"] = CurrentPage + 1 });
}
private void NavigateWithParameters(IReadOnlyDictionary<string, object?> parameters)
{
var uri = Navigation.GetUriWithQueryParameters(parameters);
Navigation.NavigateTo(uri);
}
}
Architectural Boundaries: What Doesn't Belong in the URL?
Placing state in query strings requires pragmatic boundaries. Categorize your application state cleanly:
Component Implementation State: Micro-interactions like
isDropdownOpen,isHovered,activeModalTab, or local validation flags stay inside component fields. They represent UI execution context, not navigation targets.Resource & View State (The URL): Anything representing what subset of data is actively rendering (
search,filter,sort,page,tab) belongs in the URL.Application & Global Context: Auth tokens, current tenant IDs, user permissions, and shopping carts belong in scoping services, memory state containers, or encrypted storage. Never place sensitive credentials or keys inside query parameters; URLs end up in browser history logs, web server access logs, and copy-paste buffers.
Route Parameters vs. Query Parameters
A simple heuristic for clean RESTful routing:
Route Parameters (
/customers/42): Identify what resource you are targeting.Query Parameters (
/customers/42/orders?status=Open): Define how you are filtering or viewing that resource collection.
Treat Query Strings as Untrusted Input
Because users can alter the URL directly, defensive programming is mandatory. A user typing ?page=-999 or ?sort=NonExistentColumn should never throw unhandled exception spikes in production.
Always sanitize incoming values:
private string EffectiveSort => Sort switch
{
"Name" => "Name",
"LastContact" => "LastContact",
_ => "Name" // Default safe fallback
};
Keep default state out of the query string entirely (/customers rather than /customers?page=1&sort=Name). Leaving defaults out cleans up the address bar and simplifies incoming mapping.
Conclusion
This approach isn't just about query parameters—it's about leveraging the native web platform rather than working against it.
Instead of building custom state synchronizers or cascading parameters to remember view positions, invert the flow of control:
User Interaction ──> Navigation / URL Mutation ──> [SupplyParameterFromQuery] ──> Component Render
When you let the URL drive component state, your Blazor pages become bookmarkable, easily sharable, resilient against refresh loss, and significantly easier to debug. Sometimes the best state management solution is the address bar that's been there all along.

Comments
Post a Comment