Rendering & Re-Renders
Eine Komponente sollte neu rendern, wenn sich ändert, was sie anzeigt — und sonst nicht. Deja setzt das auf drei Ebenen durch: Eine Query benachrichtigt einmal pro Zustandsübergang, eine Benachrichtigung, die die vorherige nur wiederholen würde, wird verworfen, und ein bereits eingereihter Render absorbiert die dahinter.
Der Vertrag#
Ein Request kostet eine Komponente zwei Renders: einen beim Start des Requests, einen bei dessen Abschluss. Das gilt beim ersten Fetch genauso wie beim hundertsten Refetch — und bei Erfolg genauso wie bei Fehlschlag.
| Übergang | Renders | Was die Komponente sieht |
|---|---|---|
| Request startet | 1 |
IsLoading an — ab dem zweiten Lauf zusätzlich IsReFetching |
| Request gelingt | 1 |
Data wird veröffentlicht, die Lade-Flags sind bereits zurückgesetzt |
| Request schlägt fehl | 1 |
IsError und ErrorMessage gesetzt, Flags zurückgesetzt |
| Request abgebrochen oder verdrängt | 0 |
Nichts — der verdrängende Request treibt den nächsten Render an |
Ein Success-Callback (OnSuccess / OnSuccessAsync) macht das
Veröffentlichen der Daten absichtlich zu einem eigenen Render: Der Callback verändert oft
weiteren Zustand der Komponente und darf nicht hinter einem Bildschirm laufen, der noch
die alten Daten zeigt. Ohne Callback gibt es nichts zu sequenzieren, also sind das
Veröffentlichen und das Zurücksetzen der Flags ein Render. Dasselbe gilt für die
InvalidateKeys einer Mutation.
Ein Übergang, der nichts ändert, rendert nichts#
Jede Benachrichtigung wird mit der letzten verglichen, die der Besitzer erhalten hat. Hat sich
nichts Bindbares bewegt — IsLoading, IsReFetching, IsError,
ErrorMessage, Data, UpdatedAt,
IsCachedData, IsStale — wird die Benachrichtigung verworfen und die
Komponente nicht angefasst.
Genau das hält den Cache-Pfad ehrlich, denn dort wird ein einzelnes Ereignis legitim aus zwei
Richtungen gemeldet: Dein Execute setzt IsLoading, und der gemeinsame
Cache-Eintrag meldet anschließend denselben startenden Fetch an jeden Subscriber. Beide
beschreiben einen Übergang, also rendert die Komponente einmal — und eine Komponente, die
einen Key, den jemand anderes gerade neu lädt, nur abonniert hat, rendert einmal pro
Änderung am Eintrag, nicht einmal pro Benachrichtigung, die der Eintrag aussendet.
Ein Refetch rendert weiterhin zweimal, selbst wenn die Antwort identisch ist:
IsLoading und IsReFetching gehen tatsächlich an und wieder aus,
und ein daran gebundener Spinner muss folgen. Was die Prüfung entfernt, ist der Render,
der Daten veröffentlicht hätte, die die Komponente bereits anzeigt — nicht die ehrlichen
davor und danach.
Ein Refetch, der eine neue Listeninstanz zurückgibt, rendert auch bei gleichem Inhalt —
Deja vergleicht deine DTOs nicht bei jeder Benachrichtigung in der Tiefe. Liefert deine
API frische Instanzen gleicher Daten und du willst das unterdrücken, aktiviere
DejaOptions.StructuralComparison: Das vergleicht am Cache-Eintrag mit
EqualityComparer<T>.Default und behält bei Übereinstimmung die
bestehende Referenz. Records bekommen das gratis; Klassen brauchen Equals.
Parallele Requests teilen sich Renders#
Eine Komponente, die mehrere Queries auf einmal startet — ein Dashboard, eine Detailseite, die von drei Endpunkten lädt — zahlt nicht einen Render pro Query und Übergang. Solange ein Render eingereiht ist, reiten weitere Benachrichtigungen auf ihm mit, statt eigene einzureihen; Abschlüsse, die zusammen eintreffen, werden also in einem Durchlauf absorbiert.
Der Effekt skaliert damit, wie viel eine Komponente lädt. Drei gemeinsam gestartete Queries kosten sechs Renders statt neun; der Überschuss war ein verschwendeter Durchlauf pro Query — bei jedem Lauf.
| Szenario | Renders |
|---|---|
| Ein Request, keine Callbacks | 2 |
Ein Request, mit OnSuccess |
3 |
| Drei Queries, die gemeinsam laden | 6 — höchstens zwei pro Query, weniger, wenn sich Abschlüsse überlappen |
| Eine Komponente, die einen Key beobachtet, den eine andere neu lädt | 3 — Fetch startet, Daten treffen ein, Fetch endet; angetrieben vom
Eintrag, nie von der anderen Komponente |
Das Zusammenfassen verzögert einen Render nie über die auslösende Benachrichtigung
hinaus. Sobald du ein Execute awaited hast, hat die Komponente gerendert — Tests,
die direkt nach einem await auf Markup prüfen, funktionieren also weiter, und nichts wird
hinter einem Timer gesammelt.
Renders bleiben in der besitzenden Komponente#
Nichts davon überschreitet eine Komponentengrenze. Deja-Zustand benachrichtigt genau einen Zuhörer — die besitzende Komponente — der Fetch eines Geschwisters kann dich also nie rendern. Komponenten, die sich einen Key teilen, werden vom Cache-Eintrag aktualisiert, den beide abonnieren, und jede rendert weiterhin nur sich selbst. Die Isolations-Demo macht das beobachtbar.
Live-Demo#
One component, three queries renders: 5
Renders since last reset: 5
Three queries, six renders: each contributes exactly two — one when its request starts, one when it finishes — whether you press Load all three or Refetch, and whether it is the first run or the twentieth. Before this deduplication the same three queries cost nine, because each also rendered a third time to publish data the component went on to render again a moment later.
Six is the ceiling, not a guarantee: overlapping completions can share a render, so a slow connection sometimes shows fewer. Add latency in the demo controls and the requests spread out until every transition lands on its own.
@inherits DejaComponentBase
@inject JsonPlaceholderApi Api
<div class="demo-panel">
<h4>
One component, three queries
<span class="render-counter">renders: @(++_renders)</span>
</h4>
<StateInspector Query="_first" Label="Todo 1"/>
<StateInspector Query="_second" Label="Todo 2"/>
<StateInspector Query="_third" Label="Todo 3"/>
<div class="demo-toolbar" style="margin-top:0.6rem">
<button class="demo-button primary" @onclick="LoadAll">Load all three</button>
<button class="demo-button" @onclick="RefetchSame">Refetch (same data)</button>
<button class="demo-button" @onclick="ResetCounter">Reset counter</button>
</div>
<p class="demo-note" style="margin-top:0.6rem">
Renders since last reset: <strong>@(_renders - _baseline)</strong>
</p>
</div>
<p class="demo-note">
Three queries, six renders: each contributes exactly two — one when its request starts, one
when it finishes — whether you press <strong>Load all three</strong> or
<strong>Refetch</strong>, and whether it is the first run or the twentieth. Before this
deduplication the same three queries cost nine, because each also rendered a third time to
publish data the component went on to render again a moment later.
</p>
<p class="demo-note">
Six is the ceiling, not a guarantee: overlapping completions can share a render, so a slow
connection sometimes shows fewer. Add latency in the demo controls and the requests spread out
until every transition lands on its own.
</p>
@code {
private readonly Query<TodoDto> _first = new();
private readonly Query<TodoDto> _second = new();
private readonly Query<TodoDto> _third = new();
private int _renders;
private int _baseline;
protected override Task OnInitializedAsync() => LoadAll();
private Task LoadAll() => Task.WhenAll(
_first.Execute(token => Api.GetTodoAsync(1, token), p => p.OnError = _ => { }),
_second.Execute(token => Api.GetTodoAsync(2, token), p => p.OnError = _ => { }),
_third.Execute(token => Api.GetTodoAsync(3, token), p => p.OnError = _ => { }));
private Task RefetchSame() => Task.WhenAll(_first.Refetch(), _second.Refetch(), _third.Refetch());
// reset itself causes one more render before _baseline is read
private void ResetCounter() => _baseline = _renders + 1;
}Was das ersetzt#
Das übliche Blazor-Muster beim Datenladen ist ein StateHasChanged() nach jedem
await — leicht zu vergessen und leicht zu übertreiben: Das vergessene hinterlässt einen
veralteten Bildschirm, das überflüssige rendert umsonst. Deja-Komponenten rufen es nie auf:
Zustandsübergänge treiben das Rendering an, und die Deduplizierung oben entscheidet, welche
davon einen Durchlauf wert sind.