Todo list
Full CRUD against a live API: a keyed query for the list, three mutations for create, update
and delete, and SetData applying results to the shared cache.
Open the demo controls (bottom right) and raise the latency — loading and refetching states become easy to watch. Arm a failure to see the error path.
- delectus aut autem
- quis ut nam facilis et officia qui
- fugiat veniam minus
- et porro tempora
- laboriosam mollitia et enim quasi adipisci quia provident illum
- qui ullam ratione quibusdam voluptatem quia omnis
- illo expedita consequatur quia in
- quo adipisci enim quam ut ab
JSONPlaceholder fakes writes (nothing persists server-side), so successful mutations are
applied to the cached list with DejaClient.SetData instead of refetching.
A Mutation<T> is shared by every row, so IsLoading says
a write is running — pair it with the id being written to place the spinner on the
right row.
@inherits DejaComponentBase
@inject JsonPlaceholderApi Api
@inject DejaClient Client
<div class="demo-toolbar">
<input class="demo-input" style="flex:1" placeholder="What needs doing?"
@bind="_newTitle" @bind:event="oninput" @onkeydown="@(e => e.Key == "Enter" ? Add() : Task.CompletedTask)"
disabled="@_create.IsLoading"/>
<button class="demo-button primary" @onclick="Add"
disabled="@(_create.IsLoading || string.IsNullOrWhiteSpace(_newTitle))">
@if (_create.IsLoading)
{
<span class="spinner" aria-hidden="true"></span>
<span>Adding…</span>
}
else
{
<span>Add</span>
}
</button>
<button class="demo-button" @onclick="() => _todos.Refetch()" disabled="@_todos.IsReFetching">
@if (_todos.IsReFetching)
{
<span class="spinner" aria-hidden="true"></span>
}
<span>Refetch</span>
</button>
</div>
<StateInspector Query="_todos" Label="List query"/>
<MutationInspector Mutation="_create" Label="Create"/>
<MutationInspector Mutation="_update" Label="Update"/>
<MutationInspector Mutation="_delete" Label="Delete"/>
<div class="demo-activity" role="status" aria-live="polite">
@if (Busy is { } busy)
{
<span class="spinner" aria-hidden="true"></span>
<span>@busy</span>
}
else
{
<span class="demo-activity-idle">Idle — no mutation in flight</span>
}
</div>
@if (_todos.Data is { } todos)
{
<ul class="demo-list">
@if (_create.IsLoading)
{
<li class="pending">
<span class="spinner" aria-hidden="true"></span>
<span>@_newTitle</span>
<span class="spacer"></span>
<span class="row-status">Creating…</span>
</li>
}
@foreach (var todo in todos)
{
<li class="@RowClass(todo.Id)">
<input type="checkbox" checked="@todo.Completed" @onchange="() => Toggle(todo)"
disabled="@(IsToggling(todo.Id) || IsDeleting(todo.Id))"/>
<span class="@(todo.Completed ? "done" : null)">@todo.Title</span>
<span class="spacer"></span>
@if (IsToggling(todo.Id))
{
<span class="row-status">Saving…</span>
}
else if (IsDeleting(todo.Id))
{
<span class="row-status danger">Deleting…</span>
}
<button class="demo-button danger icon" @onclick="() => Delete(todo)"
disabled="@(IsDeleting(todo.Id) || IsToggling(todo.Id))"
aria-label="@(IsDeleting(todo.Id) ? $"Deleting {todo.Title}" : $"Delete {todo.Title}")">
@if (IsDeleting(todo.Id))
{
<span class="spinner danger" aria-hidden="true"></span>
}
else
{
<span aria-hidden="true">✕</span>
}
</button>
</li>
}
</ul>
}
else if (_todos.IsLoading)
{
<ul class="demo-list">
@for (var i = 0; i < 4; i++)
{
<li class="skeleton"><span class="skeleton-bar"></span></li>
}
</ul>
}
@if (_todos.IsError)
{
<div class="demo-error">Couldn't load todos: @_todos.ErrorMessage</div>
}
@if (Error is { } error)
{
<div class="demo-error">@error</div>
}
<p class="demo-note">
JSONPlaceholder fakes writes (nothing persists server-side), so successful mutations are
applied to the cached list with <code>DejaClient.SetData</code> instead of refetching.
A <code>Mutation<T></code> is shared by every row, so <code>IsLoading</code> says
<em>a</em> write is running — pair it with the id being written to place the spinner on the
right row.
</p>
@code {
private readonly Query<IReadOnlyList<TodoDto>> _todos = new();
private readonly Mutation<TodoDto> _create = new();
private readonly Mutation<TodoDto> _update = new();
private readonly Mutation<bool> _delete = new();
private string _newTitle = string.Empty;
private int _nextLocalId = 500;
private int? _updatingId;
private int? _deletingId;
private static QueryKey Key => DocsKeys.TodoList(8);
private bool IsToggling(int id) => _update.IsLoading && _updatingId == id;
private bool IsDeleting(int id) => _delete.IsLoading && _deletingId == id;
private string? RowClass(int id)
=> IsDeleting(id) ? "deleting" : IsToggling(id) ? "pending" : null;
private string? Error => _create.IsError ? $"Create failed: {_create.ErrorMessage}"
: _update.IsError ? $"Update failed: {_update.ErrorMessage}"
: _delete.IsError ? $"Delete failed: {_delete.ErrorMessage}"
: null;
private string? Busy => _create.IsLoading ? "Creating a todo…"
: _update.IsLoading ? "Saving a todo…"
: _delete.IsLoading ? "Deleting a todo…"
: _todos.IsReFetching ? "Refetching the list…"
: null;
protected override Task OnInitializedAsync()
=> _todos.Execute(Key, token => Api.GetTodosAsync(8, token), p => p.OnError = _ => { });
private async Task Add()
{
var title = _newTitle.Trim();
if (title.Length == 0) return;
await _create.Execute(token => Api.CreateTodoAsync(new NewTodo(1, title, false), token), p =>
{
p.OnSuccess = created =>
{
_newTitle = string.Empty;
// JSONPlaceholder always answers with id 201; give local rows unique ids.
var todo = created! with { Id = _nextLocalId++ };
Client.SetData(Key, (IReadOnlyList<TodoDto>? current) => [todo, .. current ?? []]);
};
p.OnError = _ => { };
});
}
private async Task Toggle(TodoDto todo)
{
var toggled = todo with { Completed = !todo.Completed };
// Rows added locally don't exist server-side; a PUT for them would 500.
if (todo.Id >= 500)
{
Apply(toggled);
return;
}
_updatingId = todo.Id;
await _update.Execute(token => Api.UpdateTodoAsync(toggled, token), p =>
{
p.OnSuccess = _ => Apply(toggled);
p.OnError = _ => { };
p.OnSettled = _ => _updatingId = null;
});
}
private async Task Delete(TodoDto todo)
{
if (todo.Id >= 500)
{
Remove(todo);
return;
}
_deletingId = todo.Id;
await _delete.Execute(token => Api.DeleteTodoAsync(todo.Id, token), p =>
{
p.OnSuccess = _ => Remove(todo);
p.OnError = _ => { };
p.OnSettled = _ => _deletingId = null;
});
}
private void Apply(TodoDto updated)
=> Client.SetData(Key, (IReadOnlyList<TodoDto>? current)
=> [.. (current ?? []).Select(t => t.Id == updated.Id ? updated : t)]);
private void Remove(TodoDto removed)
=> Client.SetData(Key, (IReadOnlyList<TodoDto>? current)
=> [.. (current ?? []).Where(t => t.Id != removed.Id)]);
}Try the cache yourself#
Staleness is checked when a component mounts, not on a timer. Sitting on this page will never trigger a refetch on its own, no matter how long you wait — leaving and coming back is what runs the check. Open your browser's network tab and walk through it:
- Load this page. Cold cache, so the query fetches: one request, skeleton
rows,
IsLoadinglit. - Navigate to another demo and come back within 10 seconds. The list is
there immediately and the network tab stays empty — no request at all.
IsCachedDatais lit, andUpdatedAtstill shows the original fetch time. Ten seconds is the site's stale time, set once inProgram.cs. - Now wait past 10 seconds, navigate away and come back. The list still
renders instantly from cache — no spinner, no skeletons — but this time
IsReFetchinglights up while a request revalidates in the background, andUpdatedAtandReFetchCountmove when it lands.
That is the whole point of a stale time: the user never waits, and the network is only hit when the data has aged past what you decided is acceptable. To refresh without leaving, use the Refetch button — it bypasses staleness entirely — or invalidate the key from a mutation.
What else to look for#
- Adds, toggles and deletes go through
Mutation<T>; results are applied withDejaClient.SetData's updater form because JSONPlaceholder fakes writes — a refetch would resurrect the server's canonical list. - Every loading flag is on screen at once: the query's badges, a badge row per mutation, an activity line naming whatever is in flight, and spinners on the Add button, the Refetch button and the row being written.
- The disabled state of the Add button is just
_create.IsLoading, and the row spinners are_delete.IsLoading/_update.IsLoading. Because oneMutation<T>serves every row, the demo also remembers which id is being written — the flag says a write is running, the id says which row to mark. - Raise the latency in the demo controls to watch the optimistic-looking pending row for a
create, the
Deleting…row tint, and the skeleton rows on a cold load.