Rendering & re-renders
A component should re-render when what it displays changes — and not otherwise. Deja enforces that on three levels: a query notifies once per state transition, a notification that would repeat the previous one is dropped, and a render already queued absorbs the ones behind it.
The contract#
One request costs a component two renders: one when the request starts, one when it finishes. That holds whether it is the first fetch or the hundredth refetch, and whether it succeeds or fails.
| Transition | Renders | What the component sees |
|---|---|---|
| Request starts | 1 |
IsLoading on — plus IsReFetching from the second run onward |
| Request succeeds | 1 |
Data published with the loading flags already cleared |
| Request fails | 1 |
IsError and ErrorMessage set, flags cleared |
| Request cancelled or superseded | 0 |
Nothing — the superseding request drives the next render |
A success callback (OnSuccess / OnSuccessAsync) makes the data
publish a separate render, on purpose: the callback often touches other component state,
and it must not run behind a screen still showing the previous data. Without a callback
there is nothing to sequence, so the publish and the flag clear are one render. The same
applies to a mutation's InvalidateKeys.
A transition that changes nothing renders nothing#
Every notification is checked against the last one the owner received. If nothing bindable has
moved — IsLoading, IsReFetching, IsError,
ErrorMessage, Data, UpdatedAt,
IsCachedData, IsStale — the notification is dropped and the component
is not touched.
This is what keeps the cached path honest, because there a single event is legitimately
announced from two directions: your Execute sets IsLoading, and the
shared cache entry then reports the same fetch starting to every subscriber. Both describe one
transition, so the component renders once — and a component merely subscribed to a key
someone else is refetching renders once per change to the entry, not once per notification the
entry emits.
A refetch still renders twice even when the response is identical: IsLoading
and IsReFetching genuinely go on and then off, and a spinner bound to them has
to follow. What the check removes is the render that would have published data the
component was already displaying — not the honest ones on either side of it.
A refetch returning a new list instance renders even when the contents are equal — Deja will
not deep-compare your DTOs on every notification. If your API returns fresh instances of
equal data and you want that suppressed, enable
DejaOptions.StructuralComparison, which compares at the cache entry with
EqualityComparer<T>.Default and keeps the existing reference when they
match. Records get this for free; classes need Equals.
Concurrent requests share renders#
A component that starts several queries at once — a dashboard, a detail page pulling from three endpoints — does not pay a render per query per transition. While a render is queued, further notifications ride on it instead of queueing their own, so completions landing together are absorbed into one pass.
The effect scales with how much a component loads. Three queries started together cost six renders instead of nine; the surplus was one wasted pass per query, every time it ran.
| Scenario | Renders |
|---|---|
| One request, no callbacks | 2 |
One request, with OnSuccess |
3 |
| Three queries loading together | 6 — at most two each, fewer when completions overlap |
| A component watching a key another component refetches | 3 — fetch starts, data arrives, fetch ends; driven by the entry, never
by the other component |
Coalescing never defers a render past the notification that caused it. Once you have
awaited an Execute, the component has rendered — so tests that assert on markup
straight after an await keep working, and nothing is batched behind a timer.
Renders stay inside the owning component#
None of this crosses a component boundary. Deja state notifies exactly one listener — the component that owns it — so a sibling's fetch can never render you. Components sharing a key are updated by the cache entry they both subscribe to, each still rendering only itself. The isolation demo makes that observable.
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;
}What this replaces#
The usual Blazor data-fetching pattern is a StateHasChanged() after every await,
which is both easy to forget and easy to overdo — the forgotten one leaves a stale screen, the
extra one renders for nothing. Deja components call it never: state transitions drive rendering,
and the deduplication above decides which of them are worth a pass.