Component base

DejaComponentBase is the glue: it discovers the queries and mutations a component owns, re-renders the component when they change, scopes them to the component's lifetime, and cleans everything up on dispose.

Discovery at initialisation#

When OnInitialized runs, the base scans the component's fields and properties (walking the type hierarchy) and attaches to every Query<T> / Mutation<T> it finds. [Parameter], [CascadingParameter] and [Inject] members are skipped — parameters are owned by the parent that passed them.

@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();
}
Always call base.OnInitialized()

An override that skips base.OnInitialized() compiles and renders once — then the component silently stops reacting, because nothing was attached. Deja reports this once as a console.error on first render. The fix is to call the base first, conventionally on the override's first line; attaching each piece of state by hand with Observe() works, but only as a fallback where calling the base isn't possible.

Observe() — for the state discovery can't see#

Most components never call Observe(). A query held in a field is found by the scan above and attached for you — that is the whole point of the base component, and it is the path you should be on. Observe() is the same attach step, exposed by hand for the cases the scan structurally cannot reach.

There are only two:

  • The state didn't exist at initialisation. The scan runs once, inside OnInitialized. A query created later — in an event handler, or per row into a List<Query<T>> — wasn't there to be found, and nothing rescans.
  • Your override skipped base.OnInitialized(). The scan never ran at all. Calling the base is the real fix; Observe() is the fallback when you can't.
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() returns the state, so it can be used inline as above, and attaching the same instance twice is a no-op — it is safe to call defensively.

What you see when it's missing#

There is no exception and no stack trace, which is what makes this worth recognising by its symptom: the fetch succeeds but the screen never updates. Blazor renders once after your event handler returns — while the fetch is still in flight — and unattached state has no way to ask for a second render. Data really is populated; the component just never hears about it. Two quieter consequences follow from the same missing wiring:

  • The query never receives the registered DejaClient, so it bypasses the shared cache. Anything documented cached path onlyEnabled, Select, PlaceholderData, StaleTime — is silently inert.
  • It never receives ComponentToken, so an in-flight fetch isn't cancelled when the component is disposed.
Not sure whether you need it?

Ask where the query lives. Assigned to a field or property before the first render — you don't need Observe(). Created inside a method that runs after the component is already on screen — you do.

How often it re-renders#

The base does not re-render on every notification it could. A query notifies once per state transition, a notification identical to the previous one is dropped, and a render already queued absorbs the ones behind it — so one request costs two renders (start, finish) no matter how many components share the key or how many queries run at once. See rendering & re-renders for the full contract.

The single-owner rule#

Deja state notifies exactly one listener — the owning component. Passing a live Query<T> to a second component and observing it there throws: share the data instead, either by passing Data down as a [Parameter], or by giving both components their own query on the same key. This rule is what makes the isolation guarantee provable rather than aspirational.

ComponentToken#

ComponentToken is a CancellationToken cancelled when the component is disposed. The component's queries and mutations already use it — pass it explicitly only to work Deja doesn't run for you, like a direct API call from an event handler. After disposal it reads as an already-cancelled token rather than throwing.

Cleanup: override the hooks, don't redeclare#

A derived component adds cleanup by overriding the base's hooks — Dispose() for synchronous work, DisposeAsync() for asynchronous; both run during disposal, before the base detaches state and disposes the queries the component owns:

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

protected override async ValueTask DisposeAsync()
{
    await _jsModule.DisposeAsync();
    await base.DisposeAsync();
}
Guard rail: redeclared disposal throws

Do not add @implements IDisposable or @implements IAsyncDisposable to a Deja component. Blazor only calls DisposeAsync once IAsyncDisposable is present, so a redeclared DisposeAsync would replace Deja's disposal entirely (leaking attachments and in-flight fetches), and a redeclared Dispose would never run at all. The constructor detects either and throws an InvalidOperationException naming the fix.

Inheriting through a middle base#

A component does not have to inherit DejaComponentBase directly. Put your own base class in between — for a shared layout, a common query, an app-wide convention — and every leaf that inherits it behaves exactly as if it had inherited Deja's base itself:

// 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
    }
}

All three mechanisms walk the type hierarchy rather than looking only at the leaf, so depth costs you nothing:

  • Discovery scans every level, so state declared on the middle base and state declared on the leaf are both attached — including private fields on the base that the leaf cannot see.
  • The disposal guard inspects the leaf first and then each base, so @implements IDisposable on a leaf still throws at construction, and the message names the type that declared the offending method.
  • Cleanup hooks chain: the leaf's Dispose() calls the middle base's, which calls Deja's — then the base detaches state and disposes owned queries.
Every level must call base.OnInitialized()

The chain is what reaches Deja's scan. If any level overrides OnInitialized without calling the base — the middle base included — nothing is attached for the whole chain, not just that level. Deja reports it once as a console.error naming every type in the hierarchy that overrides OnInitialized, so the list is exactly the set of files to audit.

The reliable fix is to seal it. Have the middle base override OnInitialized as sealed and expose its own virtual hook; leaves then override the hook and cannot break the chain. That is the shape the demo below uses.

Live demo#

Discovery, Observe() and render isolation

Owning component renders: 4

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

#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 = _ => { });
    }
}
Inheriting through a middle base

Leaf A — todo renders: 5

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

#1 — delectus aut autem

Leaf B — user renders: 5

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

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;
}