Data and filters
A Table with an ItemsProvider asks for its rows with a DataRequest: the range,
the sort and the filter. You answer with a DataResult. The request applies itself to an EF Core query,
travels to an API as a query string or JSON, and never turns a value into code.
The request and the result
The request says which rows the component wants:
| Member | What it is |
|---|---|
Skip, Take | The range: rows to skip, and how many to take (null for every row). Page gives the range as a page number, from 1. |
Sort | A list of SortFields, a key and whether it's descending. A Table sorts by one column. |
Filter | The filter tree, or null. A Table's is Filter.All of one condition per filtered column, and any other conditions of a bound Filter. |
Search | Free text to look for in the searchable fields. A Table doesn't set it; a provider can, from your own search box: request with { Search = query }. |
Cursor | When the component pages by cursor, the Next or Previous of an earlier result. |
NeedsTotal | Whether to count. False when the component pages by cursor, where the total is optional. |
CancellationToken | Canceled when a newer request replaces this one. Pass it on. |
The result is the rows, with the Total that numbered pages need, or the Next and
Previous cursors:
new DataResult<Order>(rows, total) // numbered pages
new DataResult<Order>(rows) { Next = cursor } // by cursor, no total neededFrom a database
The simplest way needs no provider: pass an EF Core query as a Table's Items, and the database
sorts, filters and pages it. Write an ItemsProvider when you need more, such as scoping by the
signed-in user per request, an API in between, or your own search.
In a provider, ToResultAsync filters, searches and sorts a query, counts what the filter keeps (only when
NeedsTotal), and lists the range. It takes EF Core's async methods as arguments, so IzzyBlazor
doesn't depend on EF Core:
<Table TItem="Order" ItemsProvider="LoadOrders" PageSize="20">...</Table>
{
async ValueTask<DataResult<Order>> LoadOrders(DataRequest<Order> request)
{
var query = db.Orders.AsNoTracking().Where(o => o.ShopId == shopId);
return await request.ToResultAsync(query,
count: (q, ct) => q.CountAsync(ct),
list: (q, ct) => q.ToListAsync(ct));
}
}
The request carries the component's columns, which say how each key reads an item, so the provider never maps
keys to properties. Each condition becomes a LINQ expression on the column's own property, with its value as a
query parameter, and the database does the work. For a list in memory, request.ToResult(list.AsQueryable())
does the same. request.Apply(query) filters, sorts and takes the range without counting;
ApplyFiltering and ApplySorting do one part each.
If the provider throws, the Table logs the exception and shows "Couldn't load the rows." with Retry; see When loading fails. A request it canceled isn't an error.
Filters in code
A filter is a tree: a condition on one field (FieldFilter), conditions joined by and or or
(FilterGroup), or the opposite of one (NotFilter). Fields are named by their key, the
same key the address uses (?f-status= filters the field status): a column's property name
in lower case, or its SortKey (see Fields).
Filter.Where("status", FilterOperator.Equals, "Paid")
Filter.Between("total", 100, 500) // either end may be null
Filter.In("status", ["Paid", "Pending"])
Filter.All( // and; Filter.Any is or
Filter.Where("placed", FilterOperator.OnDay, DateOnly.FromDateTime(DateTime.Today)),
Filter.Not(Filter.Where("customer", FilterOperator.StartsWith, "test")))
new FieldFilter("sku", FilterOperator.Equals, "AB-1", MatchCase: true)
Bind a Table's Filter to set it from code on an interactive page. Each condition a column's panel can
show (contains for text, between, at least, at most or equals for a number, on day for a date, in for a choice) shows there; any
other, such as an or group, still applies and shows as a tag that removes it. With an ItemsProvider, the
whole filter reaches it as request.Filter:
<Table Items="orders" @bind-Filter="filter">...</Table>
{
Filter? filter = Filter.Any(
Filter.Where("status", FilterOperator.Equals, "Failed"),
Filter.Where("total", FilterOperator.GreaterThan, 1000)); // a tag, not a panel
}
A provider that passes conditions on to an API reads one with Find: the condition on that field that
every match must meet, or null.
var who = request.Filter?.Find("who")?.Value as string; // the Who column's text, or null
var (items, next) = await api.ListEventsAsync(request.Cursor, request.Take ?? 50, who);
return new DataResult<AuditEvent>(items) { Next = next };Operators
Which operators a field takes depends on its type. An operator its type doesn't take matches nothing.
| Type | Operators |
|---|---|
| Text | Equals, NotEquals, Contains, DoesNotContain, StartsWith, EndsWith, In, NotIn, IsEmpty, IsNotEmpty |
| Numbers, times | Equals, NotEquals, LessThan, LessThanOrEqual, GreaterThan, GreaterThanOrEqual, Between, In, NotIn |
| Dates | The same, and OnDay |
| Enums, booleans, Guids | Equals, NotEquals, In, NotIn |
| Anything that can be null | Also IsNull and IsNotNull |
- Text ignores case, as a Table's filters do. Set
MatchCaseon the condition to match it.IsEmptymatches empty text and null;DoesNotContainkeeps null. - Text ignores case by lowering both sides, which a database index can't serve.
FilterCase.Databaseon a Table or DataGrid, or as a field'sCase, compares text as it is and leaves it to the database's collation;FilterCase.MatchCasecompares text as it is too, which is exact in memory, though SQLite'sLIKEfor a starts-with or ends-with still ignores ASCII case. The filter's text doesn't carry this, so an endpoint that reads it, such as an export, setsCaseon its own fields. - Values are parsed for the field's type, so
"10"filters a number and"Paid"an enum. Numbers are read as the person writes them in the current culture ("1.234,5"in German), then in the invariant form ("1234.5"); dates and times in ISO form ("2026-10-28"), then the current culture's. A value that doesn't parse matches nothing, as does a condition on a field that doesn't exist. - Number bounds round the way the comparison means. On a whole-number field, "at least 2.5" is
3 or more and "at most 2.5" is 2 or less. A bound beyond what the type holds keeps every row or none.
Betweenincludes both ends, and a null end has no limit. - A day on a
DateTimeOffsetdoesn't translate on SQLite, as it reads the date on the clock where it was recorded. OnDaymatches a day. ADateTimekeeps the day's range, so an index can serve it; aDateTimeOffsetcompares the day on the clock where it was recorded.
Filters as text
Filter.Parse reads a filter written in a subset of OData's $filter, and a filter's
ToString() writes one. It's the form the request's query string uses for ?filter=:
total ge 100 and (status in ('Paid','Pending') or customer contains 'ada')| Write | For |
|---|---|
eq ne gt ge lt le | Comparisons; eq null and ne null for the null checks |
status in ('Paid','Pending') | Any of the values; not (status in (...)) for none |
customer contains 'ada' | Also contains(customer,'ada'), and startswith, endswith |
between(total,100,500) | Either end can be null, for no limit |
isempty(email) | No value or empty text; also isnotempty |
date(placed) eq 2026-10-28 | On that day |
and or not ( ) | Groups; and binds before or |
'O''Brien', -1.5, true, 2026-10-28T09:30:00Z | Values: text in single quotes (a quote doubled), numbers, true or false, dates and times, Guids |
Parsing only builds the tree: names are field keys and values stay values, so
name eq 'x'' or 1 eq 1' looks for that exact text. Text that isn't in this form doesn't parse
(TryParse returns false), and a request read from a query string with such a filter matches nothing.
Text ignores case here too, unlike OData's own contains. MatchCase isn't written, so a
condition sent this way ignores case on the server; send the request as JSON to keep it.
Fields
A field is a key, the expression that reads it, and what can be done with it: sorted, filtered, searched, with
which operators. A Table's columns are its fields. A PropertyColumn's key is its property path in
lower case (customer.name) unless it has a SortKey; a TemplateColumn's is its
SortKey, and it filters and searches by its FilterBy and sorts by its SortBy.
Where there are no columns, DataFields.Of<T>() reads them from a type: each public top-level
property a filter can compare, keyed by its name in lower case, sortable and filterable, with text searchable
and an enum's names as its choices. It reads [Display(Name)] for the caption,
[DisplayFormat] and [DataType] for the format, and leaves out
[ScaffoldColumn(false)] and [Display(AutoGenerateField = false)] properties. Or declare
fields yourself:
IReadOnlyList<DataField<Order>> fields =
[
new() { Key = "customer", Value = (Expression<Func<Order, string>>)(o => o.Customer.Name),
Sortable = true, Filterable = true, Searchable = true },
new() { Key = "total", Value = (Expression<Func<Order, decimal>>)(o => o.Total),
Sortable = true, Filterable = true,
Operators = [FilterOperator.GreaterThanOrEqual,
FilterOperator.LessThanOrEqual, FilterOperator.Between] },
];Operators narrows what a field takes. Filter builds its condition yourself, for a computed
value or one stored differently from how it's shown: it gets the condition with its values parsed for the field's
type, and returns the filter on the item, or null for the usual one. It runs for every operator, so check the one
you handle; and it matches case unless you lower both sides. A Table column takes the same Filter
parameter.
<PropertyColumn Property="o => o.Tags" Filterable Filter="TagFilter" />
{
// Tags are stored lower case, as "red;green": "Green" in the filter finds it.
static Expression<Func<Order, bool>>? TagFilter(FieldFilter f) =>
f.Operator == FilterOperator.Contains && f.Value is string tag
? o => (";" + o.Tags + ";").Contains(";" + tag.ToLower() + ";")
: null;
}From the browser to an API
In a WebAssembly or Auto app the provider runs in the browser, so it sends the request to an API.
ToQueryString() writes it for a GET (sort=-placed&skip=20&take=10&filter=total ge 100,
shown unescaped, with total=false when it doesn't need the count), and the request also serializes with
System.Text.Json for a POST. On the server, DataRequest<T>.Parse reads it back, and
ToResultAsync with the fields you declare applies it:
Both sides use the same row type, here OrderRow in a project the client and the server share:
HttpClient Api
<Table TItem="OrderRow" ItemsProvider="LoadOrders" PageSize="20">
<PropertyColumn Property="o => o.Customer" Sortable Filterable />
<PropertyColumn Property="o => o.Total" Format="C" Sortable Filterable />
</Table>
{
async ValueTask<DataResult<OrderRow>> LoadOrders(DataRequest<OrderRow> request) =>
(await Api.GetFromJsonAsync<DataResult<OrderRow>>(
$"api/orders?{request.ToQueryString()}", request.CancellationToken))!;
}using Microsoft.EntityFrameworkCore; // CountAsync, ToListAsync
app.MapGet("/api/orders", async (HttpContext context, ShopDb db) =>
{
var request = DataRequest<OrderRow>.Parse(context.Request.QueryString.Value ?? "")
with { CancellationToken = context.RequestAborted };
// At most 100 rows.
request = request with { Take = Math.Min(request.Take ?? 100, 100) };
var rows = db.Orders.AsNoTracking() // an initializer: EF Core filters and sorts on it
.Select(o => new OrderRow { Id = o.Id, Customer = o.Customer.Name, Total = o.Total });
return await request.ToResultAsync(rows, DataFields.Of<OrderRow>(),
count: (q, ct) => q.CountAsync(ct), list: (q, ct) => q.ToListAsync(ct));
});
public sealed record OrderRow
{
public int Id { get; init; }
public string Customer { get; init; } = "";
public decimal Total { get; init; }
}- The keys are the client's columns. The server's fields need the same keys. With the same row
type they match; a column on a path,
o => o.Customer.Name, iscustomer.name, so give it theSortKeyof the server's field. - Only declared fields can be reached. A condition on a key that isn't a filterable field matches
nothing, and a sort by one is left out.
DataFields.Of<T>()declares every public property, so read it from a type made for the API, not an entity with a password hash on it. - Cap the range. The request asks for its own
Take, or every row without one. - Large or deep filters match nothing. A filter of more than 1,000 nodes (conditions, groups and nots), or nested more than 64 deep, matches no rows rather than building an expression that would overflow the server's stack. The text form nests at most 32 deep.
OData endpoints
When the API is an OData endpoint, ToODataQuery() writes the request as its query options:
$filter, $orderby, $skip, $top, $count and
$search. OData compares text with case, so text that ignores case is written lower-cased on both
sides (contains(tolower(customer),'ada')), and paths use /
(customer/name). Values are written as their fields' types: a Table's request carries its fields, and
for a request you read with Parse, pass them, ToODataQuery(fields). Keys are lower case;
if the service matches property names with case, give the columns a SortKey with its names. A cursor
isn't part of OData: pass
request.Cursor on yourself.
var url = $"odata/Orders?{request.ToODataQuery()}";
// Unescaped:
// $filter=contains(tolower(customer),'ada') and total ge 100
// &$orderby=placed desc&$skip=20&$top=10&$count=trueNext steps
- Table: sorting, filters, paging by cursor and Load more, with state in the address.
- WebAssembly and Auto: setting up an app whose components run in the browser.
- Render modes: why the Table keeps its state in the address.