Queries

Eine Query<T> verfolgt einen einzelnen asynchronen Lesevorgang und stellt dessen Lebenszyklus als bindbaren Zustand bereit. Dieser Leitfaden behandelt die drei Möglichkeiten, eine Query auszuführen, und die Parameter, die eine Ausführung steuern.

Drei Execute-Überladungen#

Kurzform mit Key — der übliche Fall. Der Key meldet die Query beim gemeinsamen Cache an; alles Weitere wird über den optionalen Configure-Hook gesetzt:

private readonly Query<List<Todo>> _todos = new();

protected override Task OnInitializedAsync()
    => _todos.Execute("todos", Api.GetTodosAsync, p =>
    {
        p.StaleTime = TimeSpan.FromSeconds(30);
        p.OnError = e => Log.Warn(e);
    });

Kurzform ohne Key — ein Fetch, der den Cache nie berührt. Ein neuerer Aufruf verdrängt (und cancelt) einen älteren, noch laufenden — genau das, was eine Type-ahead-Suche braucht:

// Each keystroke: the previous in-flight search is cancelled, the newest wins.
private Task OnSearchChanged(string term)
    => _results.Execute(token => Api.SearchAsync(term, token));

Vollständige Form — alles explizit, für Aufrufstellen, die ihre Parameter programmatisch zusammenbauen:

await _todos.Execute(new QueryParameters<List<Todo>>
{
    QueryKey = QueryKey.Of("todos", "list", page),
    QueryFunction = token => Api.GetTodosAsync(page, token),
    PlaceholderData = [],
    Select = todos => [.. todos.OrderBy(t => t.Title)],
});

Der bindbare Zustand#

  • IsLoading — true, solange irgendeine Ausführung lädt.
  • IsReFetching — true, solange eine Ausführung nach der ersten lädt; steuere damit dezente Aktualisierungs-Indikatoren und lass die Daten auf dem Bildschirm.
  • IsError / ErrorMessage — der jüngste Fehlschlag; wird von einem erfolgreichen Refetch gelöscht.
  • Data — das jüngste Ergebnis.
  • ReFetchCount — wie oft die Query ausgeführt wurde.
  • UpdatedAt — wann die aktuellen Daten geladen wurden (nur auf dem gecachten Pfad).
  • IsCachedData — true, solange Data aus dem Cache stammt und während der Subscription dieser Query noch kein frischer Fetch abgeschlossen wurde.
  • IsStale — true, wenn der beobachtete Cache-Eintrag invalidiert oder älter als die effektive Stale-Time ist (auf dem ungecachten Pfad immer false).

Enabled — abhängige Queries#

Enabled = false liefert gecachte Daten, lädt aber nie — für Queries, deren Eingaben noch nicht bereitstehen (kein Benutzer ausgewählt, ein Filter noch leer). null, der Standard, bedeutet aktiviert. Sobald die Eingabe da ist, führe erneut mit Enabled = true aus. Nur auf dem gecachten Pfad.

Abhängige Requests verketten#

Wenn Key oder Argumente des zweiten Requests aus dem ersten Ergebnis stammen, starte ihn aus OnSuccessAsync. Der Callback wird awaited, jeder Schritt wartet also auf den vorherigen, und das äußere Execute ist erst abgeschlossen, wenn die ganze Kette durch ist — ein gewöhnliches if genügt daher, um auf das Ergebnis des vorherigen Schritts zu verzweigen.

await _user.Execute("user", Api.GetUserAsync, p => p.OnSuccessAsync = async user =>
{
    if (user is null) return;

    await _orders.Execute(QueryKey.Of("orders", user.Id), t => Api.GetOrdersAsync(user.Id, t),
        o => o.OnError = e => Log.Warn(e));

    // Each step sees the previous one settled
    if (_orders.Data is { Count: > 0 })
    {
        await _invoices.Execute(QueryKey.Of("invoices", user.Id), t => Api.GetInvoicesAsync(user.Id, t));
    }
});

Callbacks laufen bei jedem abgeschlossenen Execute, auch bei einem, das frische gecachte Daten ohne Fetch geliefert hat — eine Kette verhält sich bei einem Cache-Treffer also genauso wie bei einem Fetch. (Eine Query mit Enabled = false über einem leeren Cache hat kein Ergebnis weiterzureichen; sie kommt zur Ruhe, ohne Erfolg zu melden.) Dasselbe Muster funktioniert aus einer Mutation heraus — siehe Mutations.

Fan-out: ein Schreibvorgang, zwei parallele Lesevorgänge, dann ein dritter#

Die interessante Form ist keine gerade Linie. Hier läuft zuerst ein echter Schreibvorgang; bei Erfolg laden zwei Queries parallel; und wenn beide abgeschlossen sind, läuft ein vierter Request auf ihrem kombinierten Ergebnis. Die Regel, die das lesbar macht: das Awaiten ordnet die Schritte, und jedes Execute schreibt immer nur den Zustand seiner eigenen Query.

// 1 — a real write. Everything below runs only if it succeeds.
await _createTodo.Execute(t => Api.CreateTodoAsync(draft, t), p =>
{
    p.OnError = e => _error = e.Message;
    p.OnSuccessAsync = async created =>
    {
        // 2a + 2b — started, not awaited: both requests go out together.
        var author = _author.Execute(QueryKey.Of("users", "detail", created!.UserId),
            t => Api.GetUserAsync(created.UserId, t),
            o => o.OnError = e => _error = $"author: {e.Message}");

        var todos = _authorTodos.Execute(QueryKey.Of("todos", "list", "user", created.UserId),
            t => Api.GetTodosByUserAsync(created.UserId, t),
            o => o.OnError = e => _error = $"todos: {e.Message}");

        // Each query drives its own IsLoading/Data/IsError, so these two never
        // interfere — whichever finishes first clears only its own flags.
        await Task.WhenAll(author, todos);

        // 3 — runs after both, and can branch on their combined result.
        if (_author.Data is { } user && _authorTodos.Data is { Count: > 0 })
        {
            await _posts.Execute(QueryKey.Of("posts", "list", user.Id),
                t => Api.GetPostsAsync(user.Id, t),
                o => o.OnError = e => _error = $"posts: {e.Message}");
        }
    };
});

Lies die Reihenfolge an den awaits ab. Das OnSuccessAsync von Schritt 1 wird von der Mutation awaited, nichts Nachgelagertes startet also, bevor der Schreibvorgang tatsächlich erfolgreich war. Die Schritte 2a und 2b werden gestartet, bevor einer von beiden awaited wird — das ist der ganze Trick — beide Requests sind also gleichzeitig unterwegs, und das Paar kostet einen Roundtrip statt zwei. Task.WhenAll wartet dann auf den langsameren der beiden. Schritt 3 steht hinter diesem await, sieht also beide Ergebnisse abgeschlossen und kann mit einem schlichten if an sie geknüpft werden.

Weil jede Query ihren eigenen Zustand besitzt, kann sich das parallele Paar nicht gegenseitig stören: _author.IsLoading erlischt, wenn der Author-Request fertig ist — egal ob der Todos-Request noch läuft — und jede schreibt nur ihr eigenes Data, IsError und ErrorMessage. Zwei Spinner an zwei Queries lösen sich unabhängig voneinander auf, und ein Fehlschlag in 2b lässt die Daten von 2a unberührt auf dem Bildschirm. Diese Unabhängigkeit ist auch der Grund, warum jeder Schritt seinen eigenen Error-Callback trägt — ein Fehlschlag wird so dem Request zugeordnet, der ihn verursacht hat, statt am Parent aufzutauchen.

Die Lade-Flags verschachteln sich, statt zu konkurrieren. Die Mutation bleibt für die gesamte Kette IsLoading — ihr Execute ist noch nicht zurückgekehrt — während das Flag jeder Query nur ihren eigenen Request abdeckt. Binde also einen globalen „Arbeitet“-Indikator an die Mutation und Spinner pro Zeile an die einzelnen Queries.

Ketten laufen bei Invalidierung nicht erneut

Das Invalidieren eines Parent-Keys lädt diese Query neu und rendert neu, führt aber die Callback-Kette nicht erneut aus — die abhängige Query behält ihre bisherigen Daten. Ketten laufen erneut, wenn du Execute aufrufst. Invalidiere auch die abhängigen Keys, wenn sie folgen sollen.

Zwei kleinere Dinge bei langen Ketten: Gib jedem Schritt seinen eigenen Error-Callback, sonst taucht der Fehlschlag eines Kinds am Parent auf (ein unbeobachteter Fehlschlag wirft, und diese Exception propagiert aus dem OnSuccessAsync des Parents heraus); und der Parent bleibt für die ganze Kette IsLoading, binde Spinner also an die letzte Query oder an die Kombination.

Schreibvorgang → zwei parallele Lesevorgänge → noch einer

Step 1 writes. Steps 2a and 2b start together once the write succeeds and load independently — watch their two IsLoading badges light up and go out on their own. Step 3 only starts after both have settled.

1 · Create todo (write) IsLoading IsError Data: null
2a · Author (parallel) IsLoading IsReFetching IsError IsCachedData IsStale ReFetchCount: 0
2b · Author's todos (parallel) IsLoading IsReFetching IsError IsCachedData IsStale ReFetchCount: 0
3 · Posts (after both) IsLoading IsReFetching IsError IsCachedData IsStale ReFetchCount: 0
@inherits DejaComponentBase
@inject JsonPlaceholderApi Api

<div class="demo-toolbar">
    <input class="demo-input" style="flex:1" placeholder="Todo title" @bind="_title" @bind:event="oninput"/>
    <button class="demo-button primary" @onclick="Run"
            disabled="@(_create.IsLoading || string.IsNullOrWhiteSpace(_title))">
        @(_create.IsLoading ? "Running chain…" : "Create + load")
    </button>
</div>

<p class="demo-note">
    Step 1 writes. Steps 2a and 2b start together once the write succeeds and load independently —
    watch their two <code>IsLoading</code> badges light up and go out on their own. Step 3 only
    starts after both have settled.
</p>

<MutationInspector Mutation="_create" Label="1 · Create todo (write)"/>
<StateInspector Query="_author" Label="2a · Author (parallel)"/>
<StateInspector Query="_authorTodos" Label="2b · Author's todos (parallel)"/>
<StateInspector Query="_posts" Label="3 · Posts (after both)"/>

@if (_stepLog.Count > 0)
{
    <div class="demo-panel">
        <h4>Chain timeline</h4>
        <ol class="demo-list">
            @foreach (var step in _stepLog)
            {
                <li>@step</li>
            }
        </ol>
    </div>
}

@if (_error is not null)
{
    <div class="demo-error">@_error</div>
}

@code {
    private readonly Mutation<TodoDto> _create = new();
    private readonly Query<UserDto> _author = new();
    private readonly Query<IReadOnlyList<TodoDto>> _authorTodos = new();
    private readonly Query<IReadOnlyList<PostDto>> _posts = new();

    private readonly List<string> _stepLog = [];
    private string _title = string.Empty;
    private string? _error;

    private const int AuthorId = 1;

    private Task Run()
    {
        _stepLog.Clear();
        _error = null;

        return _create.Execute(
            token => Api.CreateTodoAsync(new NewTodo(AuthorId, _title.Trim(), false), token),
            p =>
            {
                p.OnSuccessAsync = async created =>
                {
                    Log($"1 · created todo #{created!.Id} for user {created.UserId}");
                    _title = string.Empty;

                    // Both start before either is awaited, so they overlap on the wire. Each writes
                    // only its own query's state, so neither can disturb the other's flags.
                    var author = _author.Execute(
                        DocsKeys.User(created.UserId),
                        t => Api.GetUserAsync(created.UserId, t),
                        o => o.OnError = Fail("2a"));

                    var authorTodos = _authorTodos.Execute(
                        DocsKeys.TodosByUser(created.UserId),
                        t => Api.GetTodosByUserAsync(created.UserId, t),
                        o => o.OnError = Fail("2b"));

                    await Task.WhenAll(author, authorTodos);
                    Log($"2 · both settled — author {_author.Data?.Name}, {_authorTodos.Data?.Count ?? 0} todos");

                    // Gated on both: step 3 needs the author id that step 2a resolved.
                    if (_author.Data is { } user && _authorTodos.Data is { Count: > 0 })
                    {
                        await _posts.Execute(
                            DocsKeys.Posts(user.Id, 1),
                            t => Api.GetPostsAsync(user.Id, 1, t),
                            o => o.OnError = Fail("3"));

                        Log($"3 · loaded {_posts.Data?.Count ?? 0} posts for {user.Name}");
                    }
                };

                p.OnError = Fail("1");
            });
    }

    private Action<Exception> Fail(string step) => e => _error = $"Step {step} failed: {e.Message}";

    private void Log(string message) => _stepLog.Add(message);
}

Select — Projektion pro Komponente#

Select transformiert den gecachten Wert, bevor er nach Data veröffentlicht wird. Es gilt pro Query, zwei Komponenten können denselben Eintrag also unterschiedlich projizieren — der Cache speichert immer den Rohwert.

PlaceholderData#

Wird als Data gerendert, solange der erste Fetch läuft und der Cache leer ist; wird nie in den Cache geschrieben. Nutze es für Skeleton-Zeilen, die wie echte Daten typisiert sind.

Joins mit Key verwerfen Parameter

Ein Execute mit demselben Key, während eines läuft, schließt sich dieser Ausführung an: Die Parameter des zweiten Aufrufs werden verworfen, und es findet keine Verdrängung statt. Verwende einen Key nicht für Aufrufe mit unterschiedlichen Parametern wieder — lass den Key weg, um stattdessen Verdrängungs-Semantik zu bekommen.

Live-Demo#

Enabled · Select · PlaceholderData
User query IsLoading IsReFetching IsError IsCachedData IsStale ReFetchCount: 1

Loading…

@placeholder · shown while the first fetch runs

With no user selected, Enabled = false: the query serves cached data but never fetches. PlaceholderData renders while the first fetch runs, and Select projects the cached value per component — the cache keeps the raw user.

@inherits DejaComponentBase
@inject JsonPlaceholderApi Api

<div class="demo-toolbar">
    <label>
        User:
        <select class="demo-input" @onchange="OnUserChanged">
            <option value="0" selected="@(_userId == 0)">— none selected —</option>
            <option value="1" selected="@(_userId == 1)">User 1</option>
            <option value="2" selected="@(_userId == 2)">User 2</option>
            <option value="3" selected="@(_userId == 3)">User 3</option>
        </select>
    </label>
    <label>
        <input type="checkbox" checked="@_uppercase" @onchange="OnUppercaseChanged"/>
        Select: uppercase the name
    </label>
</div>

<StateInspector Query="_user" Label="User query"/>

@if (_user.Data is { } user)
{
    <div class="demo-panel">
        <h4>@user.Name</h4>
        <p class="demo-note">@@@user.Username · @user.Email</p>
    </div>
}

<p class="demo-note">
    With no user selected, <code>Enabled = false</code>: the query serves cached data but never
    fetches. <code>PlaceholderData</code> renders while the first fetch runs, and
    <code>Select</code> projects the cached value per component — the cache keeps the raw user.
</p>

@code {
    private readonly Query<UserDto> _user = new();
    private int _userId;
    private bool _uppercase;

    protected override Task OnInitializedAsync() => Load();

    private Task Load() => _user.Execute(DocsKeys.User(_userId), token => Api.GetUserAsync(_userId, token), p =>
    {
        p.Enabled = _userId > 0;
        p.PlaceholderData = new UserDto(0, "Loading…", "placeholder", "shown while the first fetch runs");
        if (_uppercase)
        {
            p.Select = user => user with { Name = user.Name.ToUpperInvariant() };
        }
        p.OnError = _ => { };
    });

    private Task OnUserChanged(ChangeEventArgs e)
    {
        _userId = int.Parse((string)e.Value!);
        return Load();
    }

    private Task OnUppercaseChanged(ChangeEventArgs e)
    {
        _uppercase = (bool)e.Value!;
        return Load();
    }
}