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();
}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 aList<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 only —Enabled,Select,PlaceholderData,StaleTime— is silently inert. - It never receives
ComponentToken, so an in-flight fetch isn't cancelled when the component is disposed.
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();
}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
privatefields on the base that the leaf cannot see. - The disposal guard inspects the leaf first and then each base, so
@implements IDisposableon 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.
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#
Owning component renders: 4
#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 = _ => { });
}
}Leaf A — todo renders: 5
#1 — delectus aut autem
Leaf B — user renders: 5
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;
}