Mutations

A Mutation<T> gives writes the same bindable treatment as reads — IsLoading, IsError, ErrorMessage, Data — plus cache invalidation on success.

Running a mutation#

private readonly Mutation<Todo> _addTodo = new();

private Task Add() => _addTodo.Execute(
    token => Api.AddTodoAsync(_title, token),   // cancellation-aware shape
    p =>
    {
        p.OnSuccess = created => _title = "";
        p.OnError = e => _error = e.Message;
    });

Four function shapes#

MutationParameters<T> accepts exactly one of four functions:

Function TypeDescription
MutationFunction Func<Task<T>>Returns a value; the simplest shape.
VoidMutationFunction Func<Task>No return value; Data is left untouched.
CancellableMutationFunction Func<CancellationToken, Task<T>>Returns a value and honours the component's lifetime token — an in-flight write is abandoned when the component is disposed.
CancellableVoidMutationFunction Func<CancellationToken, Task>Cancellation-aware, no return value.

Precedence when several are set: CancellableMutationFunction wins over MutationFunction, which wins over the void shapes. Only the value-returning shapes write Data — a void mutation leaves any previous result standing. Supplying none of them throws an ArgumentException.

InvalidateKeys#

Keys listed in InvalidateKeys are invalidated — prefix-matched — after the mutation succeeds and OnSuccess has run, so a callback observing the fresh result runs before dependent queries start refetching. Every mounted query under those keys refetches in the background, in every component showing that data. It replaces manual OnSuccessAsync = _ => Reload() wiring.

p.InvalidateKeys = [QueryKey.Of("todos")];
// invalidates ["todos"], ["todos","list",1], ["todos","detail",42], …
Needs the cache

InvalidateKeys works through the registered DejaClient. Without AddDeja() the keys are silently ignored.

Chaining follow-up requests#

InvalidateKeys covers "refresh these keys afterwards". When the follow-up's key or arguments come from the mutation result — or when one request depends on another's outcome — chain from OnSuccessAsync instead. It is awaited, so each step waits for the previous one and the mutation's own Execute completes only once the chain has.

await _createOrder.Execute(() => Api.CreateOrderAsync(_draft), p => p.OnSuccessAsync = async order =>
{
    // The follow-up key comes from the result, which InvalidateKeys cannot express
    await _lines.Execute(QueryKey.Of("orders", order!.Id, "lines"),
        t => Api.GetOrderLinesAsync(order.Id, t),
        o => o.OnError = e => _error = e.Message);

    if (_lines.Data is { Count: 0 })
    {
        await _notify.Execute(() => Api.FlagEmptyOrderAsync(order.Id));
    }
});

Ordering across the two is fixed: OnSuccess runs first, then InvalidateKeys (whose refetches are awaited), then OnSettled — so a settled callback sees the invalidated queries already refetched. Queries chained this way follow the same rules as in Queries: give each step its own error callback so a failure is attributed to the request that caused it.

The unhandled-failure contract#

On failure the mutation publishes its error state and runs the error callbacks. Then comes the part worth memorising: an InvalidOperationException wrapping the original exception is thrown only when no error callback was supplied. A failure nobody observes is never lost silently — but wire any error callback (OnError is enough) and the failure stays handled instead of escaping into the component lifecycle, where a rethrow from an event handler would tear down the component over an ordinary failed write.

Cancellation is not a failure: if the component is disposed mid-write, there's no error state, no callbacks, and nothing thrown at a caller that is already gone. A mutation started after disposal (a queued handler racing teardown) returns without issuing the request.

Live demo#

Create + invalidate
Create mutation IsLoading IsError Data: null
List query (invalidated on success) IsLoading IsReFetching IsError IsCachedData IsStale ReFetchCount: 1UpdatedAt: 20:32:30
  • delectus aut autem
  • quis ut nam facilis et officia qui
  • fugiat veniam minus
  • et porro tempora
@inherits DejaComponentBase
@inject JsonPlaceholderApi Api

<div class="demo-toolbar">
    <input class="demo-input" style="flex:1" placeholder="Todo title" @bind="_title" @bind:event="oninput"/>
    <button class="demo-button primary" @onclick="Create"
            disabled="@(_create.IsLoading || string.IsNullOrWhiteSpace(_title))">
        @(_create.IsLoading ? "Creating…" : "Create todo")
    </button>
</div>

<MutationInspector Mutation="_create" Label="Create mutation"/>
<StateInspector Query="_todos" Label="List query (invalidated on success)"/>

@if (_create.Data is { } created)
{
    <div class="demo-panel">
        <h4>Server response</h4>
        <p class="demo-note">Id @created.Id — “@created.Title” (JSONPlaceholder fakes the write, so the list refetch won't contain it)</p>
    </div>
}

@if (_create.IsError)
{
    <div class="demo-error">@_create.ErrorMessage</div>
}

@if (_todos.Data is { } todos)
{
    <ul class="demo-list">
        @foreach (var todo in todos.Take(4))
        {
            <li>@todo.Title</li>
        }
    </ul>
}

@code {
    private readonly Query<IReadOnlyList<TodoDto>> _todos = new();
    private readonly Mutation<TodoDto> _create = new();
    private string _title = string.Empty;

    protected override Task OnInitializedAsync()
        => _todos.Execute(DocsKeys.TodoList(4), token => Api.GetTodosAsync(4, token), p => p.OnError = _ => { });

    private Task Create() => _create.Execute(
        token => Api.CreateTodoAsync(new NewTodo(1, _title.Trim(), false), token),
        p =>
        {
            // After OnSuccess, every mounted query under the "todos" prefix refetches in the
            // background — watch IsReFetching light up on the list query below.
            p.InvalidateKeys = [DocsKeys.Todos];
            p.OnSuccess = _ => _title = string.Empty;
            p.OnError = _ => { };
        });
}