Rask
Rask
v0.23.1-alpha.0.496
You're reading the Rask v0.23.1-alpha.0.496 guides. Updated .View source
All guides

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 Fields part. Fields(("page", 1), ("status", "done")) and Fields(("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 BeginTransactionAsync it 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();
        }
    }
}
Live result
Page 1 of 3fresh
#1 Ada — a kite
#2 Grace — records
#3 Linus — a lamp
#4 Margaret — tea
Picked parcel

Paused — pick a parcel to load it.

Parcels holding

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

Confirm