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, solangeDataaus 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.
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.
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.
@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.
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#
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();
}
}