Todo-Liste
Vollständiges CRUD gegen eine echte API: eine Query mit Key für die Liste, drei Mutations
für Anlegen, Ändern und Löschen, und SetData, das die Ergebnisse in den
gemeinsamen Cache schreibt.
Öffne die Demo-Steuerung (unten rechts) und erhöhe die Latenz — Lade- und Refetch-Zustände lassen sich so bequem beobachten. Aktiviere einen Fehler, um den Fehlerpfad zu sehen.
- 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)]);
}Probiere den Cache selbst aus#
Ob Daten veraltet sind, wird geprüft, wenn eine Komponente mountet — nicht per Timer. Auf dieser Seite sitzen zu bleiben löst nie von selbst einen Refetch aus, egal wie lange du wartest — erst Weggehen und Zurückkommen führt die Prüfung aus. Öffne den Netzwerk-Tab deines Browsers und geh es Schritt für Schritt durch:
- Lade diese Seite. Der Cache ist kalt, die Query lädt also: ein Request,
Skeleton-Zeilen,
IsLoadingleuchtet. - Navigiere zu einer anderen Demo und komm innerhalb von 10 Sekunden zurück.
Die Liste ist sofort da und der Netzwerk-Tab bleibt leer — gar kein Request.
IsCachedDataleuchtet, undUpdatedAtzeigt noch den Zeitpunkt des ursprünglichen Ladens. Zehn Sekunden ist die Stale-Time dieser Site, einmal gesetzt inProgram.cs. - Warte jetzt über die 10 Sekunden hinaus, navigiere weg und komm zurück.
Die Liste rendert weiterhin sofort aus dem Cache — kein Spinner, keine Skeletons — aber
diesmal leuchtet
IsReFetching, während ein Request im Hintergrund revalidiert, undUpdatedAtundReFetchCountspringen um, sobald er landet.
Genau das ist der Sinn einer Stale-Time: Der Nutzer wartet nie, und das Netzwerk wird nur dann belastet, wenn die Daten älter geworden sind, als du für akzeptabel erklärt hast. Um zu aktualisieren, ohne die Seite zu verlassen, nutze den Refetch-Button — er umgeht die Aktualitätsprüfung komplett — oder invalidiere den Key aus einer Mutation.
Worauf du sonst noch achten solltest#
- Anlegen, Umschalten und Löschen laufen über
Mutation<T>; die Ergebnisse werden mit der Updater-Form vonDejaClient.SetDataangewendet, weil JSONPlaceholder Schreibvorgänge nur vortäuscht — ein Refetch würde die kanonische Liste des Servers wiederherstellen. - Jedes Lade-Flag ist gleichzeitig auf dem Bildschirm: die Badges der Query, eine Badge-Zeile pro Mutation, eine Aktivitätszeile, die benennt, was gerade läuft, und Spinner auf dem Add-Button, dem Refetch-Button und der Zeile, die gerade geschrieben wird.
- Der Disabled-Zustand des Add-Buttons ist schlicht
_create.IsLoading, und die Zeilen-Spinner sind_delete.IsLoading/_update.IsLoading. Weil eine einzigeMutation<T>alle Zeilen bedient, merkt sich die Demo zusätzlich, welche Id gerade geschrieben wird — das Flag sagt, dass ein Schreibvorgang läuft, die Id sagt, welche Zeile zu markieren ist. - Erhöhe die Latenz in der Demo-Steuerung, um die optimistisch wirkende Pending-Zeile beim
Anlegen, die
Deleting…-Einfärbung der Zeile und die Skeleton-Zeilen bei einem kalten Laden zu beobachten.