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.

Make the state machine visible

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.

Todo list — live
List query IsLoading IsReFetching IsError IsCachedData IsStale ReFetchCount: 1UpdatedAt: 20:32:20
Create IsLoading IsError Data: null
Update IsLoading IsError Data: null
Delete IsLoading IsError Data: set
Idle — no mutation in flight
  • 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&lt;T&gt;</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:

  1. Load this page. Cold cache, so the query fetches: one request, skeleton rows, IsLoading lit.
  2. 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. IsCachedData is lit, and UpdatedAt still shows the original fetch time. Ten seconds is the site's stale time, set once in Program.cs.
  3. Now wait past 10 seconds, navigate away and come back. The list still renders instantly from cache — no spinner, no skeletons — but this time IsReFetching lights up while a request revalidates in the background, and UpdatedAt and ReFetchCount move 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 with DejaClient.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 one Mutation<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.