Komponenten-Basisklasse

DejaComponentBase ist das Bindeglied: Es entdeckt die Queries und Mutations, die eine Komponente besitzt, rendert die Komponente neu, wenn sie sich ändern, bindet sie an die Lebensdauer der Komponente und räumt beim Dispose alles auf.

Entdeckung bei der Initialisierung#

Wenn OnInitialized läuft, scannt die Basisklasse die Felder und Eigenschaften der Komponente (entlang der Typhierarchie) und hängt sich an jede gefundene Query<T> / Mutation<T>. [Parameter]-, [CascadingParameter]- und [Inject]-Member werden übersprungen — Parameter gehören dem Parent, der sie übergeben hat.

@inherits DejaComponentBase

@code {
    // Discovered and attached automatically — the component re-renders on every state change.
    private readonly Query<List<Todo>> _todos = new();
    private readonly Mutation<Todo> _addTodo = new();
}
Rufe immer base.OnInitialized() auf

Ein Override, das base.OnInitialized() auslässt, kompiliert und rendert einmal — dann hört die Komponente stillschweigend auf zu reagieren, weil nichts angehängt wurde. Deja meldet das einmalig als console.error beim ersten Render. Die Lösung ist, zuerst die Basisklasse aufzurufen, üblicherweise in der ersten Zeile des Overrides; jedes Stück Zustand von Hand mit Observe() anzuhängen funktioniert, aber nur als Fallback, wo der Aufruf der Basisklasse nicht möglich ist.

Observe() — für den Zustand, den die Entdeckung nicht sieht#

Die meisten Komponenten rufen Observe() nie auf. Eine Query in einem Feld wird vom obigen Scan gefunden und für dich angehängt — genau das ist der Sinn der Basiskomponente, und auf diesem Pfad solltest du bleiben. Observe() ist derselbe Anhänge-Schritt, von Hand verfügbar gemacht für die Fälle, die der Scan strukturell nicht erreichen kann.

Es gibt nur zwei:

  • Der Zustand existierte bei der Initialisierung noch nicht. Der Scan läuft einmal, innerhalb von OnInitialized. Eine später erzeugte Query — in einem Event-Handler, oder pro Zeile in eine List<Query<T>> — war nicht da, um gefunden zu werden, und nichts scannt erneut.
  • Dein Override hat base.OnInitialized() ausgelassen. Der Scan lief nie. Der eigentliche Fix ist der Aufruf der Basisklasse; Observe() ist der Fallback, wenn das nicht geht.
private Query<Detail>? _detail;

private Task ShowDetail(int id)
{
    _detail = Observe(new Query<Detail>());
    return _detail.Execute(QueryKey.Of("detail", id), t => Api.GetDetailAsync(id, t));
}

Observe() gibt den Zustand zurück, es lässt sich also wie oben inline verwenden, und dieselbe Instanz zweimal anzuhängen ist ein No-op — der Aufruf ist auch defensiv sicher.

Was du siehst, wenn es fehlt#

Es gibt keine Exception und keinen Stacktrace — genau deshalb lohnt es sich, das Problem am Symptom zu erkennen: der Fetch gelingt, aber der Bildschirm aktualisiert sich nie. Blazor rendert einmal, nachdem dein Event-Handler zurückgekehrt ist — während der Fetch noch läuft — und nicht angehängter Zustand hat keine Möglichkeit, einen zweiten Render anzufordern. Data ist wirklich befüllt; die Komponente erfährt nur nie davon. Aus derselben fehlenden Verdrahtung folgen zwei leisere Konsequenzen:

  • Die Query erhält nie den registrierten DejaClient und umgeht damit den gemeinsamen Cache. Alles, was als nur auf dem gecachten Pfad dokumentiert ist — Enabled, Select, PlaceholderData, StaleTime — ist stillschweigend wirkungslos.
  • Sie erhält nie ComponentToken, ein laufender Fetch wird beim Dispose der Komponente also nicht abgebrochen.
Unsicher, ob du es brauchst?

Frag dich, wo die Query lebt. Vor dem ersten Render einem Feld oder einer Eigenschaft zugewiesen — dann brauchst du Observe() nicht. In einer Methode erzeugt, die läuft, wenn die Komponente schon auf dem Bildschirm ist — dann schon.

Wie oft neu gerendert wird#

Die Basisklasse rendert nicht bei jeder Benachrichtigung neu, bei der sie könnte. Eine Query benachrichtigt einmal pro Zustandsübergang, eine Benachrichtigung, die mit der vorherigen identisch ist, wird verworfen, und ein bereits eingereihter Render absorbiert die dahinter — ein Request kostet also zwei Renders (Start, Ende), egal wie viele Komponenten sich den Key teilen oder wie viele Queries gleichzeitig laufen. Siehe Rendering & Re-Renders für den vollständigen Vertrag.

Die Ein-Besitzer-Regel#

Deja-Zustand benachrichtigt genau einen Listener — die besitzende Komponente. Eine lebende Query<T> an eine zweite Komponente zu übergeben und dort zu beobachten wirft: Teile stattdessen die Daten, entweder indem du Data als [Parameter] nach unten reichst, oder indem beide Komponenten ihre eigene Query auf demselben Key bekommen. Diese Regel macht die Isolationsgarantie beweisbar statt bloß erhofft.

ComponentToken#

ComponentToken ist ein CancellationToken, das beim Dispose der Komponente gecancelt wird. Die Queries und Mutations der Komponente nutzen es bereits — reiche es nur explizit weiter für Arbeit, die Deja nicht für dich ausführt, etwa einen direkten API-Aufruf aus einem Event-Handler. Nach dem Dispose liest es sich als bereits gecanceltes Token, statt zu werfen.

Aufräumen: überschreibe die Hooks, deklariere nicht neu#

Eine abgeleitete Komponente ergänzt Aufräumarbeiten, indem sie die Hooks der Basisklasse überschreibt — Dispose() für synchrone Arbeit, DisposeAsync() für asynchrone; beide laufen während des Disposals, bevor die Basisklasse den Zustand abhängt und die Queries disposed, die die Komponente besitzt:

protected override void Dispose()
{
    _timer?.Dispose();
    base.Dispose();
}

protected override async ValueTask DisposeAsync()
{
    await _jsModule.DisposeAsync();
    await base.DisposeAsync();
}
Leitplanke: neu deklariertes Disposal wirft

Füge einer Deja-Komponente kein @implements IDisposable oder @implements IAsyncDisposable hinzu. Blazor ruft DisposeAsync nur auf, wenn IAsyncDisposable vorhanden ist; ein neu deklariertes DisposeAsync würde Dejas Disposal also vollständig ersetzen (und dabei Anhänge sowie laufende Fetches leaken), und ein neu deklariertes Dispose würde gar nie laufen. Der Konstruktor erkennt beides und wirft eine InvalidOperationException, die die Lösung benennt.

Vererbung über eine Zwischen-Basisklasse#

Eine Komponente muss DejaComponentBase nicht direkt erben. Setze deine eigene Basisklasse dazwischen — für ein gemeinsames Layout, eine gemeinsame Query, eine appweite Konvention — und jedes Blatt, das sie erbt, verhält sich genau so, als hätte es Dejas Basisklasse selbst geerbt:

// The middle base — inherits Deja's base once, on behalf of every page.
public abstract class DemoPageBase : DejaComponentBase
{
    // Declared here, discovered for every leaf that inherits this base.
    protected readonly Query<TodoDto> Shared = new();

    // Sealed: a leaf cannot break the base.OnInitialized() chain by forgetting to call through.
    protected sealed override void OnInitialized()
    {
        base.OnInitialized();
        OnPageInitialized();
    }

    protected virtual void OnPageInitialized() { }
}

// The leaf — inherits DejaComponentBase indirectly, and behaves identically.
@inherits DemoPageBase

@code {
    private readonly Query<UserDto> _user = new();   // discovered too

    protected override void OnPageInitialized()
        => _user.Execute(DocsKeys.User(UserId), t => Api.GetUserAsync(UserId, t));

    protected override void Dispose()
    {
        _cleanup.Dispose();
        base.Dispose();   // runs DemoPageBase's cleanup, then Deja's
    }
}

Alle drei Mechanismen laufen die Typhierarchie entlang und betrachten nicht nur das Blatt — die Tiefe kostet dich also nichts:

  • Die Entdeckung scannt jede Ebene: Zustand auf der Zwischen-Basisklasse und Zustand auf dem Blatt werden beide angehängt — auch private-Felder der Basisklasse, die das Blatt gar nicht sieht.
  • Die Disposal-Leitplanke prüft zuerst das Blatt und dann jede Basisklasse: @implements IDisposable auf einem Blatt wirft weiterhin im Konstruktor, und die Meldung benennt den Typ, der die Methode deklariert hat.
  • Die Cleanup-Hooks verketten sich: Das Dispose() des Blatts ruft das der Zwischen-Basisklasse auf, dieses ruft Dejas auf — danach löst die Basisklasse den Zustand und disposed die eigenen Queries.
Jede Ebene muss base.OnInitialized() aufrufen

Die Aufrufkette ist das, was Dejas Scan erreicht. Wenn irgendeine Ebene OnInitialized überschreibt, ohne die Basisklasse aufzurufen — auch die Zwischen-Basisklasse — wird für die gesamte Kette nichts angehängt, nicht nur für diese Ebene. Deja meldet das einmalig als console.error und benennt dabei jeden Typ der Hierarchie, der OnInitialized überschreibt — die Liste ist also genau die Menge der zu prüfenden Dateien.

Der verlässliche Fix ist, es zu versiegeln: Lass die Zwischen-Basisklasse OnInitialized als sealed überschreiben und einen eigenen virtuellen Hook anbieten; die Blätter überschreiben dann den Hook und können die Kette nicht brechen. Genau diese Form nutzt die Demo unten.

Live-Demo#

Entdeckung, Observe() und Render-Isolation

Owning component renders: 4

Declared query (auto-attached) IsLoading IsReFetching IsError IsCachedData IsStale ReFetchCount: 1UpdatedAt: 20:33:29

#3 — fugiat veniam minus

The field-declared query was discovered and attached in base.OnInitialized() — no StateHasChanged, no IDisposable anywhere in this component. The runtime-created query only re-renders this component because it's passed through Observe().

@inherits DejaComponentBase
@inject JsonPlaceholderApi Api

<div class="demo-panel">
    <h4>
        Owning component
        <span class="render-counter">renders: @(++_renders)</span>
    </h4>

    <StateInspector Query="_declared" Label="Declared query (auto-attached)"/>

    @if (_declared.Data is { } todo)
    {
        <p>#@todo.Id@todo.Title</p>
    }

    @if (_late is not null)
    {
        <StateInspector Query="_late" Label="Late query (attached via Observe)"/>
        @if (_late.Data is { } user)
        {
            <p>@user.Name (@user.Email)</p>
        }
    }

    <div class="demo-toolbar" style="margin-top:0.6rem">
        <button class="demo-button" @onclick="() => _declared.Refetch()">Refetch declared</button>
        <button class="demo-button" @onclick="CreateLateQuery" disabled="@(_late is not null)">
            Create a query at runtime
        </button>
    </div>
</div>

<p class="demo-note">
    The field-declared query was discovered and attached in <code>base.OnInitialized()</code> —
    no <code>StateHasChanged</code>, no <code>IDisposable</code> anywhere in this component. The
    runtime-created query only re-renders this component because it's passed through
    <code>Observe()</code>.
</p>

@code {
    // Discovered automatically when base.OnInitialized() scans this component's fields.
    private readonly Query<TodoDto> _declared = new();

    // Created later, so discovery has already run — Observe() attaches it explicitly.
    private Query<UserDto>? _late;

    private int _renders;

    protected override Task OnInitializedAsync()
        => _declared.Execute(DocsKeys.TodoDetail(3), token => Api.GetTodoAsync(3, token), p => p.OnError = _ => { });

    private Task CreateLateQuery()
    {
        _late = Observe(new Query<UserDto>());
        return _late.Execute(DocsKeys.User(1), token => Api.GetUserAsync(1, token), p => p.OnError = _ => { });
    }
}
Vererbung über eine Zwischen-Basisklasse

Leaf A — todo renders: 5

Shared query (declared on the middle base) IsLoading IsReFetching IsError IsCachedData IsStale ReFetchCount: 1UpdatedAt: 20:33:29
Own query (declared on this leaf) IsLoading IsReFetching IsError IsCachedData IsStale ReFetchCount: 1UpdatedAt: 20:33:29

#1 — delectus aut autem

Leaf B — user renders: 5

Shared query (declared on the middle base) IsLoading IsReFetching IsError IsCachedData IsStale ReFetchCount: 1UpdatedAt: 20:33:29
Own query (declared on this leaf) IsLoading IsReFetching IsError IsCachedData IsStale ReFetchCount: 1UpdatedAt: 20:33:29

Ervin Howell (Shanna@melissa.tv)

Neither leaf inherits DejaComponentBase directly — both inherit DemoPageBase, which inherits it for them. The scan walks the whole hierarchy, so the shared query on the middle base and each leaf's own query are all discovered and attached. Each leaf still re-renders only for the state it owns.

@inherits DejaComponentBase
@inject JsonPlaceholderApi Api

@code {
    // Declared on the middle base, not the leaf. The discovery scan walks up the hierarchy, so
    // this is attached for every component that inherits this base.
    protected readonly Query<TodoDto> Shared = new();

    protected JsonPlaceholderApi Client => Api;

    protected int Renders;

    // Sealed so a leaf cannot break the base.OnInitialized() chain by forgetting to call through.
    protected sealed override void OnInitialized()
    {
        base.OnInitialized();
        Shared.Execute(DocsKeys.TodoDetail(1), t => Client.GetTodoAsync(1, t), p => p.OnError = _ => { });
        OnPageInitialized();
    }

    protected virtual void OnPageInitialized()
    {
    }

    // The middle base gets cleanup too — the leaf's override chains into this one.
    protected override void Dispose()
    {
        BaseDisposeRan = true;
        base.Dispose();
    }

    protected bool BaseDisposeRan;
}