Einführung
Deja erledigt drei Dinge für Blazor: Es beseitigt den Boilerplate-Code rund um API-Aufrufe, es cacht die geladenen Daten, und es hält diese Daten über alle Komponenten hinweg synchron, die sie anzeigen.
Warum „Deja“?#
Von déjà vu — schon gesehen. Genau das ist ein Cache: Beim zweiten Mal, wenn du etwas anfragst, hast du es schon einmal gesehen — du bekommst es sofort zurück, statt erneut auf das Netzwerk zu warten. Navigiere zu einer Seite, geh zurück, komm wieder — die Liste ist schon da. Zwei Komponenten fragen im selben Moment denselben Key an — ein Request geht raus, beide bekommen die Antwort. Eine Mutation ändert die Daten — jede Komponente, die diesen Key schon gesehen hat, lädt sich selbst neu.
Das Gefühl von „das habe ich doch schon geladen“ ist der Kern der Bibliothek. Daher der Name.
Wer React Query (TanStack Query) aus der React-Welt kennt, wird das Modell wiedererkennen: Deja bringt diesen Umgang mit Server-State — Queries, Mutations und ein gemeinsamer, keybasierter Cache — in idiomatischem C# nach Blazor.
Das Problem#
Jede Blazor-Komponente, die Daten lädt, baut am Ende von Hand dieselbe Maschinerie: ein
bool _loading-Flag, ein try/catch für die
Fehlermeldung, ein IDisposable mit einer CancellationTokenSource,
damit beim Wegnavigieren keine Requests weiterlaufen, und StateHasChanged()-Aufrufe
überall dort, wo eine Continuation landen könnte. Multipliziert mit jeder Seite der App.
Dieselbe Komponente mit Deja#
@page "/todos"
@implements IDisposable
@inject TodoApi Api
@if (_loading) { <Spinner/> }
@if (_error is not null)
{
<Alert>@_error</Alert>
}
<ul>
@foreach (var todo in _todos ?? [])
{
<li>@todo.Title</li>
}
</ul>
@code {
private List<Todo>? _todos;
private bool _loading;
private string? _error;
private readonly CancellationTokenSource _cts = new();
protected override async Task OnInitializedAsync()
{
_loading = true;
try
{
_todos = await Api.GetTodosAsync(_cts.Token);
}
catch (OperationCanceledException)
{
// navigated away — swallow
}
catch (Exception e)
{
_error = e.Message;
}
finally
{
_loading = false;
StateHasChanged();
}
}
public void Dispose()
{
_cts.Cancel();
_cts.Dispose();
}
}@page "/todos"
@inherits DejaComponentBase
@inject TodoApi Api
@if (_todos.IsLoading) { <Spinner/> }
@if (_todos.IsError)
{
<Alert>@_todos.ErrorMessage</Alert>
}
<ul>
@foreach (var todo in _todos.Data ?? [])
{
<li>@todo.Title</li>
}
</ul>
@code {
private readonly Query<List<Todo>> _todos = new();
protected override Task OnInitializedAsync()
=> _todos.Execute("todos", Api.GetTodosAsync);
}
Die Komponente deklariert eine Query<T> und bindet deren Eigenschaften.
DejaComponentBase entdeckt die Query bei der Initialisierung, rendert die
Komponente neu, wenn ihr Zustand voranschreitet, und disposed sie — samt Abbruch des laufenden
Requests — wenn die Komponente entfernt wird. Es gibt kein Flag, keinen Catch-Block, kein
Token-Geflecht und nirgendwo ein StateHasChanged.
Das zweite Problem: Prop Drilling#
Der Boilerplate oben fällt pro Komponente an — die übliche Reaktion ist deshalb, ihn nicht zu
wiederholen: einmal auf der Seite laden, die Daten als Parameter nach unten reichen und einen
EventCallback nach oben, damit ein Kind, das etwas ändert, die Seite um ein
Neuladen bitten kann. Der Request sitzt nur deshalb an der Wurzel, damit ihn nicht zwei Kinder
doppelt auslösen.
Diese Entscheidung breitet sich aus. Jede Komponente zwischen der Seite und derjenigen, die
die Daten wirklich braucht, bekommt einen [Parameter], den sie nicht nutzt, und
einen Callback, den sie nur weiterreicht. Verschiebt man das Kind, muss die ganze Kette neu
verdrahtet werden. Kommt ein dritter Konsument in einem anderen Ast des Baums dazu, lädt er
entweder selbst — dann gibt es zwei Kopien desselben Datensatzes, die auseinanderlaufen
können — oder der Fetch wandert noch weiter nach oben und die Kette wird länger.
@* Todos.razor — owns the request so the children don't duplicate it *@
<TodoLayout Todos="_todos" OnChanged="Reload"/>
@code {
private List<Todo>? _todos;
// …loading flag, error, CTS, try/catch — as above
private async Task Reload()
=> _todos = await Api.GetTodosAsync(_cts.Token);
}
@* TodoLayout.razor — wants neither, forwards both *@
<TodoToolbar Todos="Todos" OnChanged="OnChanged"/>
<TodoTable Todos="Todos" OnChanged="OnChanged"/>
@code {
[Parameter] public List<Todo>? Todos { get; set; }
[Parameter] public EventCallback OnChanged { get; set; }
}
@* TodoTable.razor — three levels down, still on parameters *@
@foreach (var todo in Todos ?? []) { <TodoRow Todo="todo" OnChanged="OnChanged"/> }
@code {
[Parameter] public List<Todo>? Todos { get; set; }
[Parameter] public EventCallback OnChanged { get; set; }
private async Task Delete(int id)
{
await Api.DeleteTodoAsync(id);
await OnChanged.InvokeAsync(); // ask the page to refetch
}
}@* Todos.razor — no parameters, no callbacks *@
<TodoLayout/>
@* TodoLayout.razor — just a layout again *@
<TodoToolbar/>
<TodoTable/>
@* TodoTable.razor — asks for the data it needs, wherever it lives *@
@inherits DejaComponentBase
@inject TodoApi Api
@foreach (var todo in _todos.Data ?? []) { <TodoRow Todo="todo"/> }
@code {
private readonly Query<List<Todo>> _todos = new();
private readonly Mutation<bool> _delete = new();
protected override Task OnInitializedAsync()
=> _todos.Execute("todos", Api.GetTodosAsync);
private Task Delete(int id) => _delete.Execute(new MutationParameters<bool>
{
CancellableVoidMutationFunction = t => Api.DeleteTodoAsync(id, t),
InvalidateKeys = [QueryKey.Of("todos")],
});
}
Rechts wird nichts durchgereicht und nichts zurückgerufen. Seite, Toolbar und Tabelle
deklarieren jeweils den Key, der sie interessiert; der Cache dedupliziert sie zu einem
einzigen Request — dreimal fragen kostet also so viel wie einmal fragen. Die Mutation nennt
den Key, den sie invalidiert, und jede Komponente, die ihn hält, lädt neu — auch solche
mehrere Ebenen entfernt, von denen die Mutation nie gehört hat. TodoLayout in
der Mitte bleibt ein Layout: kein Parameter, den es nicht nutzt, kein Callback, den es nur
weiterreicht.
Damit ist die Platzierung der Daten keine Architekturentscheidung mehr. Eine Komponente kann an jede Stelle im Baum wandern oder in einer ganz anderen Seite landen und funktioniert weiter — sie hängt an einem Key, nicht an einem Vorfahren.
Mehr als nur weniger Boilerplate#
Dass Flags und try/catch aus der Komponente verschwinden, fällt zuerst auf — es ist aber das kleinste der drei Dinge, die Deja leistet.
1. Kein Boilerplate#
- Bindbarer Zustand —
IsLoading,IsError,ErrorMessage,Data,IsReFetching,IsCachedData,IsStale. - Abbruch — eine neuere Ausführung verdrängt und bricht eine ältere ab, und das Disposen der Komponente stoppt, was gerade läuft. Siehe Abbruch.
- Mutations — dieselbe bindbare Behandlung für Schreibvorgänge. Siehe Mutations.
2. Caching#
- Sofortiges Rendern — eine Query mit bekanntem Key zeichnet beim ersten Frame aus dem Cache und revalidiert im Hintergrund, falls sie stale ist.
- Deduplizierung — zehn Komponenten, die gleichzeitig denselben Key anfragen, erzeugen app-weit einen einzigen Request. Siehe der Cache.
3. Synchronisierung#
- Invalidierung per Key — eine Mutation invalidiert bei Erfolg Keys per Präfix, und jede Komponente mit diesen Daten lädt sich selbst neu. Keine Events zu verdrahten, keine Parent-Callbacks, die einen Reload erzwingen müssen.
- Eine Quelle der Wahrheit — derselbe Key bedeutet überall dieselben Daten; zwei Komponenten können nie auseinanderlaufen und zwei Versionen eines Datensatzes zeigen.
Und es bleibt günstig#
- Isolation per Design — Zustand benachrichtigt genau eine besitzende Komponente, Geschwister rendern einander also nie neu. Sieh es dir in der Isolations-Demo an.
- Minimale Re-Renders — ein Request kostet zwei Renders (Start, Ende), ein Übergang, der nichts Bindbares ändert, rendert gar nicht, und parallele Requests teilen sich einen Render, statt je einen einzureihen. Siehe Rendering & Re-Renders.
Jede Demo auf dieser Seite ist Deja selbst, laufend gegen eine echte API — öffne die Demo-Steuerung (unten rechts), um Latenz hinzuzufügen oder Fehler zu injizieren, und sieh zu, wie die State-Machine reagiert.