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 | Type | Description |
|---|---|---|
| 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], …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#
- 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 = _ => { };
});
}