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
Der eine Fall, der einen dritten Render kostet

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.

Das ist Deduplizierung, kein Render-Budget

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.

Data wird per Referenz verglichen

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#

Render-Zähler über drei parallele Queries

One component, three queries renders: 5

Todo 1 IsLoading IsReFetching IsError IsCachedData IsStale ReFetchCount: 1
Todo 2 IsLoading IsReFetching IsError IsCachedData IsStale ReFetchCount: 1
Todo 3 IsLoading IsReFetching IsError IsCachedData IsStale ReFetchCount: 1

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.