Rask.Query — the dispatcher, cached
Rask.Query wraps IDispatcher in a cache: request dedup, staleness, background refetch and
invalidation, for Rask components. It is TanStack Query's model in C#, down to the defaults — so an
app whose front end is React and whose admin pages are Rask components behaves the same way on both
sides.
builder.Services.AddRaskQuery();
Registered scoped, which on the Server host means one cache per live session. Rask creates a service scope per session, so one visitor can never be served another's data. That is not a setting to get right; it is the only arrangement this package offers, because a process-wide cache in a multi-user host is a data leak with a plausible excuse.
A query
QueryClient reaches the session's cache from anywhere a component runs — its constructor, a property,
Render or an event handler — with nothing to inject. Declare a query in whichever of three places
suits the component; all three follow a route parameter, a query-string value or a prop by themselves,
so there is nothing to call when one changes.
In Render, from the current values. Route and query parameters and props are bound by the time
Render runs, so pass them as they are:
[Route("/orders")]
public sealed partial class OrdersPage : Component
{
[QueryParam] public int Page { get; set; } = 1;
protected override Component? Render()
{
var orders = QueryClient.Query(new GetOrders(Page));
return orders.IsLoading ? Spinner() : OrderTable(orders.Data);
}
}
The same call returns the same query every render, re-pointed at whatever this render asks for — so
?page=2 shows page two, and a render that asks for page one again costs nothing. A call is known by
where it is and which time it runs there in this render, so a query inside a loop is one per row, and one
inside an if does not disturb the others. A query a render stops asking for is set aside as that render
returns, and its entry is collected like any other nothing is watching.
In a property or the constructor, from a lambda. The lambda runs at every read, and a different answer re-points the query. It first runs at the first read, never in the constructor, so it never sees a parameter that has not been bound yet:
Query<IReadOnlyList<Order>> Orders => field ??= QueryClient.Query(() => new GetOrders(Page));
// or
private readonly Query<IReadOnlyList<Order>> _orders;
public OrdersPage() => _orders = QueryClient.Query(() => new GetOrders(Page));
A lambda that returns null means the input is not there yet: the query is paused — nothing fetched, not
loading — until it returns a message. That is the whole of a dependent query:
Query<Customer> Customer => field ??= QueryClient.Query(() => Selected is { } id ? new GetCustomer(id) : null);
Injected. IQueryClient offers the same queries and is what code with no component takes — a
hosted service, a test. QueryClient throws outside a session rather than guess at one, because a cache
that is not the session's would serve one visitor another's data.
A query that is a function
Data that does not arrive through CQRS — a Rask.Data read face, a third-party HTTP call — is a function under a key. Give it the value it depends on and the fetch is handed that same value, so a fetch still running when the value changes caches under its own key, never the new one:
// in Render
var hits = QueryClient.Query(QueryKey.For<Person>(), _search,
s => Person.Where(p => p.Name.Contains(s)));
// in a property: the same, with a lambda for the value
Query<List<PersonRead>> Hits => field ??= QueryClient.Query(QueryKey.For<Person>(), () => _search,
s => Person.Where(p => p.Name.Contains(s)));
The key is [..prefix, value]. An unchanged value is compared before any key is built, so a value type —
a tuple of several included, () => (Tab, Page) — costs a read nothing.
Keys
The message is the key. Rask messages are records, so structural equality comes free:
new GetOrders(Page: 1) written in two components is one entry and one round trip, with no key
string to invent and nothing to keep in sync when a property is added. That is the whole reason this
wraps the dispatcher rather than an arbitrary callback.
Underneath, a key is an ordered list of parts — the same shape TanStack Query uses. A message
derives [typeof(GetOrders), message]:
QueryKey.Of(typeof(GetOrders), new GetOrders(1))
The type comes first so that invalidation can match a prefix:
QueryClient.Invalidate<GetOrders>(); // every page, every filter — one prefix match
Writing your own
Write a key when you want a hierarchy that spans message types, or for data that does not arrive through CQRS at all:
var list = QueryClient.Query(new GetOrders(page), key: QueryKey.Of("orders", "list", QueryKey.Fields(("page", page))));
var detail = QueryClient.Query(new GetOrder(id), key: QueryKey.Of("orders", "detail", id));
QueryClient.Invalidate(QueryKey.Of("orders")); // both of them
Two rules, and they are TanStack's:
- Order matters across parts.
["orders", "list"]is not["list", "orders"]. That is what makes a prefix mean anything. - Order does not matter inside a
Fieldspart.Fields(("page", 1), ("status", "done"))andFields(("status", "done"), ("page", 1))are the same key, so two components writing the same filter differently share one entry instead of silently doubling the cache.
A Fields part in a filter is matched as a subset:
QueryClient.Invalidate(QueryKey.Of("orders", QueryKey.Fields(("status", "done"))));
// every page of the done ones, whatever else their key carries
QueryKey.Fields takes named pairs rather than an anonymous object on purpose: reflecting over one
would warn under the trimmer on a WASM publish, and this package has to publish clean there.
A string key can never collide with a derived one — the first part of a derived key is a Type,
and of a string key a string — so both live in the same cache safely.
About a type
Data with a type but no message — a Rask.Data aggregate's read face, say — takes a key that starts with the type, so no string has to match between the query and whatever makes it stale:
var people = QueryClient.Query(QueryKey.For<Person>("active"),
Person.Where(p => p.Active));
QueryKey.For<Person>("active") is [typeof(Person), "active"], so Invalidate<Person>(),
[Invalidates(typeof(Person))] and Command(invalidates: typeof(Person)) all reach it by prefix, and a
renamed type renames the key with it.
Invalidating
Invalidate<GetOrders>() |
Every entry for that message type. |
Invalidate(QueryKey.Of("orders")) |
Every entry whose key starts with that. |
Invalidate(key.Only()) |
That one entry, and nothing beneath it. |
Invalidate(query.Key.Only()) |
This query's own entry, without restating how its key is built. |
Invalidate(key => …) |
Whatever a prefix cannot say. |
InvalidateAll() |
Everything in this session. |
Invalidating marks entries stale rather than evicting them: anything on screen refetches at once, anything not rendered refetches when something next observes it. The user keeps looking at the old value until the new one arrives, which is the point.
Prefer a key that expresses the relationship over a predicate. A predicate is invisible to anyone reading the query's own declaration.
Commands
What TanStack calls a mutation is a CQRS command here, and it is sent with the same verb the
dispatcher uses, Send: the only thing the query client adds is the invalidation afterwards.
A command declares what it makes out of date, on itself:
[Invalidates(typeof(GetOrders), typeof(GetOrderCount))] // two message types
[Invalidates("orders")] // one key prefix
public sealed record ShipOrder(Guid Id) : ICommand;
await QueryClient.Send(new ShipOrder(id)); // throws if the handler does
Several types are several prefixes; several strings are one path of several parts. The asymmetry is deliberate — each reads the way its own thing is written — and the attribute allows multiples so a command can declare both.
Declared on the command rather than passed at the call site, because the relationship belongs to the thing that causes it: a new screen shipping the same command gets the same invalidation for free, and adding an affected query is one edit in one place rather than a hunt through call sites. A stale list after a save that clearly succeeded is the most common complaint about every cache of this kind, and it is almost always a missing invalidation somebody had to remember to write.
For a command you want to render — whether it is in flight, whether it failed — hold a Command<T>
(TanStack's useMutation):
var ship = QueryClient.Command<ShipOrder>(); // in Render: the same command every render
Button.Disabled(ship.IsPending)
.OnClick(async () => await ship.Send(new ShipOrder(id)))
[ship.IsPending ? "Shipping…" : "Ship"]
In a loop — a Ship button per row — pass key: row.Id so each row keeps its own pending state; without
it, one call site is one command however many rows it runs for. Command<ShipOrder> Ship => field ??= QueryClient.Command<ShipOrder>() holds one in a property instead.
Command<T>.Send does not throw: it runs from an event handler, where an exception has
nowhere to go, so the failure lands on Error and Status for the component to render. Use
QueryClient.Send when you want the exception.
What a command can tell a render:
Status |
Idle (never sent, or reset) · Pending · Success · Error — with IsIdle, IsPending, IsSuccess, IsError |
Error |
What the last send threw |
Variables |
The command last sent — set as it is sent, so a pending render can say $"Shipping #{ship.Variables?.Id}…" |
Data |
Command<TCommand, TResult> only: the last successful result, there by the time Status says Success |
Reset() |
Back to Idle, clearing all of the above |
Optimistic updates
An edit made at send time, from the query already on screen, so it sees what is being sent:
var orders = QueryClient.Query(new GetOrders(Page));
var ship = QueryClient.Command<ShipOrder>();
.OnClick(async () => await ship.Send(new ShipOrder(id))
.Optimistically(orders.Optimistic(list => [.. list.Where(o => o.Id != id)])))
The row disappears at once. On success the command's invalidation refetches and the server's answer
replaces the guess; on failure every edit is put back, in reverse, and the error lands on Error. Pass
several — .Optimistically(list.Optimistic(…), count.Optimistic(n => n - 1)) — and all of them are
snapshotted before any is sent, so a failure never leaves half of them applied. An edit is aimed at
whatever its query shows now, so a function query takes one as readily as a message query, and a function
command sends one the same way: save.Send(ct => Person.Create(…)).Optimistically(people.Optimistic(…)).
Nothing cached means nothing is edited: a row the server never confirmed is never invented.
A function
Work that is not a CQRS record — a Rask.Data write, a third-party HTTP call — is a function. It has
nowhere to carry [Invalidates], so the command is created with what it makes out of date, and handed
the work on every send, so the lambda captures what this click is about:
var save = QueryClient.Command(invalidates: typeof(Person));
Button.Disabled(save.IsPending)
.OnClick(async () => await save.Send(ct => Person.Create(model, cancellationToken: ct)))
["Add"]
Send(ct => …) never throws either; Send<T> infers T from the lambda and returns what it
produced, or default on failure. Each key is a prefix, a string or a type converts to one, and several
are several prefixes: Command(invalidates: [typeof(Person), "dashboard"]).
Prefer a record wherever there is one: its invalidation travels with it to every screen that sends it.
A function's lives where the command is created. It runs where the component runs — on the Server host
that is the server, so a typed HttpClient injected through the constructor keeps its secrets there; in
a WASM app it is the browser, where CORS applies and nothing is secret. Calling your own backend over
HTTP from a component is the case to avoid: that is what a CQRS message is for.
Writes refresh queries by themselves
In a Rask app a Rask.Data write needs no invalidation at all. Once a save commits, every query about the types it wrote refetches on the screen of the session that made it:
var people = QueryClient.Query(QueryKey.For<Person>("active"), Person.Where(p => p.Active));
.OnClick(() => Person.Create(model)) // the list above refetches — nothing else to write
The same holds in a WebAssembly app with no Rask server behind it: AddRaskData<AppDbContext>() beside
AddRaskQuery() wires it, from Rask.Data's own browser build — there is nothing else to register.
It reaches exactly what QueryClient.Invalidate<Person>() reaches: a key built with QueryKey.For<Person>,
and anything a command names with [Invalidates(typeof(Person))]. A child entity counts as its aggregate —
adding an OrderLine refreshes Order queries. What it cannot reach is a message query such as
GetPeople: that is keyed by its own type, and the write cannot know which messages read the table, so the
command still names it with [Invalidates(typeof(GetPeople))].
So a command around such a write names nothing — QueryClient.Command() — and is there for what a render
wants from it: IsPending to grey the button, Error to say what went wrong.
The notes below run it in the browser: the list is a query over Note.OrderByDescending(…), and adding a note is a
Note.Create with no invalidation written anywhere — the list refetches on its own.
Three things it deliberately does not do:
- Refresh another session. The cache is per session, and another user's screen is not this write's to touch; it catches up on its next fetch.
- Run outside a session. A background job or a hosted service writes with no screen waiting, so nobody is told.
- Report a write that might not stand. Inside an explicit
BeginTransactionAsyncit waits for the commit, and a rollback tells nobody — a refetch before the commit could read the rows as they were and cache that.
Staying fresh
A save already refreshes the screen of the session that made it. Person.Create(model) refetches every query
about people beside the form, and that costs nothing and needs no call — it is the list of exceptions just above.
What it deliberately does not do is reach anyone else's screen. That is declared, once, in the two places the facts already live — and never at the call site:
public sealed class Order : Aggregate<Guid, Broadcast> { } // the model announces its saves
[Live(typeof(Order))] // the query says what it reads
public sealed record GetOrders(int Page) : IQuery<IReadOnlyList<OrderRead>>;
Nothing changes in the component. Render keeps saying what to show, not how it is kept fresh:
var orders = QueryClient.Query(new GetOrders(Page)); // refetches when anyone writes an order
A query keyed by the thing it reads needs no attribute at all. QueryKey.For<Order>(…) already names the entity,
so a Rask.Data read face is live the moment the model opts in:
var people = QueryClient.Query(QueryKey.For<Person>("active"),
Person.Where(p => p.Active));
Why a message query has to say it. new GetOrders(Page) is keyed by GetOrders, not by Order — a write cannot
know which message types read the table. That is exactly why a command states [Invalidates(typeof(Order))], and
[Live] is its mirror on the read side. A query that declares nothing simply is not live; it is never guessed at.
Who may see it. Nobody new. The query already decided who may read the data, so a refetch of it is admitted by the same rule — there is no second policy to write, and no way to be told about a row you could not have queried.
What it costs. One extra refetch when the entity is written, and nothing at all when it is not. The session keeps one listener however many live queries read it. The change a subscription replays when it opens is ignored, because the fetch that brought the page up already reflects it — so a page load never pays for a write that happened before it.
One process, for now. Like every subscription, this reaches the process it was published in. Behind a load balancer a page on the other server still catches up on its next refetch rather than the moment the write lands.
Subscriptions
A query asks; a subscription is told. QueryClient.Subscribe<T>() is declared where a query is and read the same way, and
every event published afterwards — by a command, a job, another server — lands in it and re-renders the component:
var orders = QueryClient.Query(new GetOrders(Page));
var shipped = QueryClient.Subscribe<OrderShipped>();
shipped.Into(orders, (list, e) => [.. list.Select(o => o.Id == e.OrderId ? o with { Status = e.Status } : o)]);
.Into patches the query on screen with no round trip. Watching the events about one record with an ISubscription<T>,
the watch policy that decides who may open it, a stream that is a function, and a WebAssembly front end subscribing on
its server are all in subscriptions.
Try it
Every shape above on one small page. The parcel list is a query declared in Render that follows the URL's
?page= — watch the address bar — and keeps the previous page on screen while the next one loads. Details feeds
a dependent query that stays paused until you pick a parcel. The contents search is a function query keyed
QueryKey.For<Parcel>(input). Each Ship button is its own keyed command: it disables itself while pending, and
its success refetches the page and the picked parcel, because ShipParcel names both with [Invalidates].
Nothing here loads data by hand: each query follows its input, keeps its cache, and refetches when a Ship command it is named by succeeds.
using System.Globalization;
using Rask.Core.Routing;
using Rask.Querying;
namespace Rask.Site.Features;
// Rask.Query's four shapes on one small page, none of which loads anything by hand:
// 1. a query declared in Render that follows the URL's ?page=, keeping the old page on screen while the next loads,
// 2. a dependent query, paused until a parcel is picked,
// 3. a function query over a local source, keyed QueryKey.For<Parcel>(input),
// 4. a command per row whose IsPending disables its own button, and whose success refetches the queries it names.
public sealed partial class QueryParcelsDemo(RouteState route, ParcelStore store) : Component
{
private static readonly QueryOptions KeepPrevious = new() { KeepPreviousData = true };
private int? _picked;
private string _contents = "book";
// 2. Dependent: the lambda returns null until a parcel is picked, and a null message pauses the query.
private Query<Parcel?> Picked => field ??= QueryClient.Query(() => _picked is { } id ? new GetParcel(id) : null);
// 3. A function query: not a CQRS message, just a key and a fetch. The input is read again on each render.
private Query<IReadOnlyList<Parcel>> Search => field ??= QueryClient.Query(
QueryKey.For<Parcel>(), () => _contents, (contents, ct) => store.SearchAsync(contents, ct));
// A demo is not a routed page, so it reads ?page= from the route rather than through [QueryParam].
private int PageNumber =>
route.Query.TryGetValue("page", out var values) && int.TryParse(values.FirstOrDefault(), CultureInfo.InvariantCulture, out var page) && page > 0
? page
: 1;
protected override Component? Render()
{
// 1. Declared in Render: the same call hands back the same query each render, re-pointed at the new page.
var parcels = QueryClient.Query(new GetParcelPage(PageNumber), KeepPrevious);
var pages = parcels.Data?.Pages ?? 1;
return Div.Id("query-demo").Class("flex flex-col gap-4")[
Div.Class("flex gap-2 items-center flex-wrap")[
Ui.Button.Id("query-prev").Disabled(PageNumber <= 1).OnClick(() => GoTo(PageNumber - 1))["Previous"],
Span.Id("query-page")[$"Page {PageNumber} of {pages}"],
Ui.Button.Id("query-next").Disabled(PageNumber >= pages).OnClick(() => GoTo(PageNumber + 1))["Next"],
Span.Id("query-status").Class("text-zinc-500 dark:text-white/70 text-sm")[
parcels switch
{
{ IsLoading: true } => "loading…",
{ IsPlaceholderData: true } => "showing the previous page",
_ => "fresh",
}]
],
Ui.Table.Id("query-rows").Aria("busy", parcels.Data is null ? "true" : "false")[Ui.TableRows[
parcels.Data is { } page ? page.Rows.Select(Row) : Enumerable.Range(0, ParcelStore.PageSize).Select(Placeholder)]],
Div.Class("flex flex-col gap-1")[
Strong["Picked parcel"],
P.Id("query-picked").Class("mb-0")[Picked.FetchStatus switch
{
FetchStatus.Paused => "Paused — pick a parcel to load it.",
_ when Picked.Data is { } p => $"#{p.Id} to {p.Recipient}: {p.Contents}, {(p.Shipped ? "shipped" : "waiting")}",
_ => "loading…",
}]
],
Div.Class("flex flex-col gap-1")[
Div.Class("flex gap-2 items-center flex-wrap")[
Strong["Parcels holding"],
Ui.Button.Id("query-search-books").OnClick(() => _contents = "book")["books"],
Ui.Button.Id("query-search-tea").OnClick(() => _contents = "tea")["tea"]
],
P.Id("query-search").Class("mb-0")[Search.Data is { } hits
? $"{_contents}: " + string.Join(", ", hits.Select(p => $"#{p.Id}"))
: "loading…"]
]
];
}
private Component Row(Parcel parcel)
{
// 4. Keyed by the row, so each row's button has its own pending state.
var ship = QueryClient.Command<ShipParcel>(key: parcel.Id);
var shipLabel = ship.IsPending ? "shipping…" : "Ship";
return Ui.TableRow.Key(parcel.Id).Class("query-row")[
Ui.TableCell[$"#{parcel.Id} {parcel.Recipient} — {parcel.Contents}"],
Ui.TableCell.End[
parcel.Shipped
? Ui.Badge.Sm.Color(Ui.Color.Green).Class("query-shipped")["shipped"]
: Ui.Button.Sm.Class("query-ship").Disabled(ship.IsPending)
.OnClick(async () => await ship.Send(new ShipParcel(parcel.Id)))[shipLabel],
" ",
Ui.Button.Sm.Class("query-pick").OnClick(() => _picked = parcel.Id)["Details"]
]
];
}
// Until the first page arrives, a full page of rows shaped exactly like a waiting parcel's, so the list keeps its
// height and its markup when the data lands. Only the text and data-loading change.
private static Component Placeholder(int index) =>
Ui.TableRow.Key($"placeholder-{index}").Class("query-row").Data("loading", null)[
Ui.TableCell["loading…"],
Ui.TableCell.End[
Ui.Button.Sm.Class("query-ship").Disabled().Loading(false)["Ship"],
" ",
Ui.Button.Sm.Class("query-pick").Disabled().Loading(false)["Details"]
]
];
private static void GoTo(int page)
{
if (page <= 1)
{
Go.Without("page").Pushing();
}
else
{
Go.With("page", page.ToString(System.Globalization.CultureInfo.InvariantCulture)).Pushing();
}
}
}| #1 Ada — a kite | |
| #2 Grace — records | |
| #3 Linus — a lamp | |
| #4 Margaret — tea |
Paused — pick a parcel to load it.
book: #6, #12
Defaults
TanStack's, deliberately: StaleTime 0 and GcTime five minutes. A query is stale the moment it
resolves — so anything that observes it again refetches in the background while showing what it has —
and an entry nothing is watching is collected five minutes later.
QueryOptions covers StaleTime, GcTime, Retry, RefetchInterval and KeepPreviousData.
A key that a handler moves
A paged or filtered list re-keys its query from a handler — _page = page, and the query reads
new GetOrders(_page). The render that follows the handler waits for that fetch, within the handler budget
of 150 ms (routing), so a fetch that is back in time is
one frame: the new pager and the new rows together, and IsLoading is never seen.
A slower fetch is shown at the budget as the query stands, and what it stands as is yours to choose:
QueryClient.Query(() => new GetOrders(_page)); // rows give way to IsLoading
QueryClient.Query(() => new GetOrders(_page), new QueryOptions { KeepPreviousData = true }); // the rows before stay
KeepPreviousData is off by default, as in TanStack Query. With it the page before stays in Data until the
next arrives (IsPlaceholderData says so). A Ui.Table dims its rows by itself while the pager or a sortable
heading is waiting (tables), so the page needs no
IsPlaceholderData branch to grey them.
A table drawn from a query
Ui.Table.Of(query) hands the table the query itself, and the table reads IsLoading and IsPlaceholderData for
you: the headings stay, the first load shows skeleton rows (as many as the pager's page holds), a query that found
nothing shows one row saying so, and every later load (a page, a sort, a filter, a tab) shows skeleton rows of exactly
the size of the rows on screen from the moment of the press, in the browser, until the new rows are on the page. With
KeepPreviousData or without it, nothing under the table moves.
var orders = QueryClient.Query(() => new GetOrders(_page));
Ui.Table.Of(orders).Paginate(Ui.Pagination.Paginator(new UiPaginator { Page = _page, Total = 1200 }).OnPage(p => _page = p))[
Ui.TableColumns[Ui.TableColumn["Order"], Ui.TableColumn["Customer"]],
Ui.TableRows[(orders.Data ?? []).Select(o => Ui.TableRow.Key(o.Id)[Ui.TableCell[o.Number], Ui.TableCell[o.Customer]])]
]
See also
docs/cqrs.md— the dispatcher this wraps.