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
The one case that costs a third 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.

This is deduplication, not a render budget

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.

Data is compared by reference

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#

Render counts across three concurrent 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;
}

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.