Introduction
Deja does three things for Blazor: it removes the boilerplate around talking to an API, it caches the data you fetched, and it keeps that data synchronized across every component showing it.
Why "Deja"?#
From déjà vu — already seen. That is exactly what a cache is: the second time you ask for something, you have seen it before, so you get it back instantly instead of waiting for the network again. Navigate to a page, go back, return — the list is already there. Two components ask for the same key at the same moment — one request goes out, both get the answer. A mutation changes the data — every component that has seen that key refetches itself.
The feeling of "I have already loaded this" is the whole point of the library. Hence the name.
If you have used React Query (TanStack Query) in the React world, the model will feel familiar: Deja brings that approach to server state — queries, mutations and a shared keyed cache — to Blazor in idiomatic C#.
The problem#
Every Blazor component that fetches data ends up hand-rolling the same machinery: a
bool _loading flag, a try/catch for the error message,
an IDisposable with a CancellationTokenSource so navigating away
doesn't leave requests running, and StateHasChanged() calls sprinkled wherever a
continuation might land. Multiply by every page in the app.
The same component with Deja#
@page "/todos"
@implements IDisposable
@inject TodoApi Api
@if (_loading) { <Spinner/> }
@if (_error is not null)
{
<Alert>@_error</Alert>
}
<ul>
@foreach (var todo in _todos ?? [])
{
<li>@todo.Title</li>
}
</ul>
@code {
private List<Todo>? _todos;
private bool _loading;
private string? _error;
private readonly CancellationTokenSource _cts = new();
protected override async Task OnInitializedAsync()
{
_loading = true;
try
{
_todos = await Api.GetTodosAsync(_cts.Token);
}
catch (OperationCanceledException)
{
// navigated away — swallow
}
catch (Exception e)
{
_error = e.Message;
}
finally
{
_loading = false;
StateHasChanged();
}
}
public void Dispose()
{
_cts.Cancel();
_cts.Dispose();
}
}@page "/todos"
@inherits DejaComponentBase
@inject TodoApi Api
@if (_todos.IsLoading) { <Spinner/> }
@if (_todos.IsError)
{
<Alert>@_todos.ErrorMessage</Alert>
}
<ul>
@foreach (var todo in _todos.Data ?? [])
{
<li>@todo.Title</li>
}
</ul>
@code {
private readonly Query<List<Todo>> _todos = new();
protected override Task OnInitializedAsync()
=> _todos.Execute("todos", Api.GetTodosAsync);
}
The component declares a Query<T> and binds its properties.
DejaComponentBase discovers the query at initialisation, re-renders the component
when its state advances, and disposes it — cancelling the in-flight request — when the
component is removed. There is no flag, no catch block, no token plumbing and no
StateHasChanged anywhere.
The second problem: prop drilling#
The boilerplate above is per component, so the usual reaction is to stop repeating it: fetch
once in the page, pass the data down as parameters, and pass an
EventCallback back up so a child that changes something can ask the page to
reload. The request lives at the root purely to keep two children from firing it twice.
That decision spreads. Every component between the page and the one that actually needs the
data grows a [Parameter] it does not use and a callback it only forwards. Move
the child, and the whole chain has to be re-threaded. Add a third consumer on another branch
of the tree, and either it re-fetches on its own — now there are two copies of the same
record, free to drift — or the fetch is hoisted even higher and the chain gets longer.
@* Todos.razor — owns the request so the children don't duplicate it *@
<TodoLayout Todos="_todos" OnChanged="Reload"/>
@code {
private List<Todo>? _todos;
// …loading flag, error, CTS, try/catch — as above
private async Task Reload()
=> _todos = await Api.GetTodosAsync(_cts.Token);
}
@* TodoLayout.razor — wants neither, forwards both *@
<TodoToolbar Todos="Todos" OnChanged="OnChanged"/>
<TodoTable Todos="Todos" OnChanged="OnChanged"/>
@code {
[Parameter] public List<Todo>? Todos { get; set; }
[Parameter] public EventCallback OnChanged { get; set; }
}
@* TodoTable.razor — three levels down, still on parameters *@
@foreach (var todo in Todos ?? []) { <TodoRow Todo="todo" OnChanged="OnChanged"/> }
@code {
[Parameter] public List<Todo>? Todos { get; set; }
[Parameter] public EventCallback OnChanged { get; set; }
private async Task Delete(int id)
{
await Api.DeleteTodoAsync(id);
await OnChanged.InvokeAsync(); // ask the page to refetch
}
}@* Todos.razor — no parameters, no callbacks *@
<TodoLayout/>
@* TodoLayout.razor — just a layout again *@
<TodoToolbar/>
<TodoTable/>
@* TodoTable.razor — asks for the data it needs, wherever it lives *@
@inherits DejaComponentBase
@inject TodoApi Api
@foreach (var todo in _todos.Data ?? []) { <TodoRow Todo="todo"/> }
@code {
private readonly Query<List<Todo>> _todos = new();
private readonly Mutation<bool> _delete = new();
protected override Task OnInitializedAsync()
=> _todos.Execute("todos", Api.GetTodosAsync);
private Task Delete(int id) => _delete.Execute(new MutationParameters<bool>
{
CancellableVoidMutationFunction = t => Api.DeleteTodoAsync(id, t),
InvalidateKeys = [QueryKey.Of("todos")],
});
}
On the right nothing is passed and nothing is called back. The page, the toolbar and the
table each declare the key they care about; the cache deduplicates them into a single
request, so asking three times costs the same as asking once. The mutation names the key it
invalidates and every component holding it refetches — including ones several levels away
that the mutation has never heard of. TodoLayout in the middle stays a layout:
no parameter it does not use, no callback it only forwards.
The consequence is that data placement stops being an architectural decision. A component can move anywhere in the tree, or be dropped into a second page entirely, and it keeps working — it depends on a key, not on an ancestor.
More than boilerplate removal#
Cutting the flags and try/catch out of a component is the part you notice first, but it is the smallest of the three things Deja does.
1. No boilerplate#
- Bindable state —
IsLoading,IsError,ErrorMessage,Data,IsReFetching,IsCachedData,IsStale. - Cancellation — a newer execution supersedes and cancels an older one, and component disposal aborts whatever is in flight. See cancellation.
- Mutations — the same bindable treatment for writes. See mutations.
2. Caching#
- Instant renders — a keyed query that has been seen before paints from cache on the first frame, then revalidates in the background if it is stale.
- Deduplication — ten components asking for the same key at the same time produce one request, app-wide. See the cache.
3. Synchronization#
- Invalidation by key — a mutation invalidates keys by prefix on success, and every component holding that data refetches itself. No events to wire up, no parent callbacks passed down to force a reload.
- One source of truth — the same key means the same data everywhere, so two components can never drift out of sync showing two versions of one record.
And it stays cheap#
- Isolation by design — state notifies exactly one owning component, so siblings never re-render each other. Watch it in the isolation demo.
- Minimal re-renders — one request costs two renders (start, finish), a transition that changes nothing bindable renders nothing at all, and concurrent requests share a render instead of queueing one each. See rendering & re-renders.
Every demo on this site is Deja itself running against a live API — open the demo controls (bottom right) to add latency or inject failures and watch the state machine react.