Mutation API
MutationController
The core lifecycle controller for a single mutation.
| Type parameter | Role |
|---|---|
TData | The value returned by the mutator on success |
TVariables | The value passed to mutate() |
TContext | The snapshot returned by onMutate (used for rollback) |
Constructor
MutationController({
required MutatorFunction<TData, TVariables> mutator,
MutationOptions<TData, TVariables, TContext>? options,
MutationTracker? tracker,
Map<String, Object?>? metadata,
})
final controller = MutationController<Post, String, List<Post>?>(
mutator: (title) => api.createPost(title),
options: MutationOptions(
onMutate: (title) async => client.getQueryData(['posts']),
onError: (err, _, prev) async => client.restoreQueryData(['posts'], prev),
onSuccess: (_, __, ___) async => client.invalidate(['posts']),
),
tracker: client,
metadata: {'category': 'content', 'resource': 'post'},
);
Properties
id
final String id
Unique identifier for this controller instance. Format: mutation_N (monotonically increasing). Used as the key in QoraClient.activeMutations and MutationUpdate.mutatorId.
mutator
final MutatorFunction<TData, TVariables> mutator
The async function that performs the write. Named mutator to mirror fetcher in fetchQuery.
options
final MutationOptions<TData, TVariables, TContext>? options
Lifecycle callbacks and retry configuration. See MutationOptions below.
tracker
final MutationTracker? tracker
Optional tracker (typically QoraClient) that receives state-change notifications for DevTools observability. When null, the controller operates in standalone mode.
metadata
final Map<String, Object?>? metadata
Arbitrary key-value pairs forwarded verbatim to every MutationUpdate emitted by this controller. Use for DevTools labelling without modifying the core schema.
state
MutationState<TData, TVariables> get state
The current synchronous state snapshot.
stream
Stream<MutationState<TData, TVariables>> get stream
A per-subscriber stream that immediately emits the current state on listen (synchronous capture), then forwards every subsequent transition.
Supports multiple concurrent subscribers. Each subscription gets its own stream backed by the shared broadcast controller: no events are lost between listen() and the first microtask.
Methods
mutate
Future<TData?> mutate(TVariables variables)
Execute the mutation. State sequence:
- Calls
onMutate(variables)→ result becomesTContext - →
MutationPending - Calls
mutator(variables)(with optional retry) - →
MutationSuccess+onSuccess+onSettled, or →MutationFailure+onError+onSettled
Returns TData on success, null on failure. Errors are never re-thrown to the caller: they are captured in MutationFailure state.
Throws StateError if the controller has been disposed.
reset
void reset()
Transition back to MutationIdle. Also removes this controller from tracker.activeMutations.
Throws StateError if the controller has been disposed.
dispose
void dispose()
Release all resources and close the internal stream. Removes the controller from the tracker silently (no event emitted). Must be called when the controller is no longer needed.
After dispose, any call to mutate or reset throws StateError.
MutationOptions
Per-mutation lifecycle callbacks and retry configuration.
Constructor
const MutationOptions({
Future<TContext?> Function(TVariables variables)? onMutate,
Future<void> Function(TData data, TVariables variables, TContext? context)? onSuccess,
Future<void> Function(Object error, TVariables variables, TContext? context)? onError,
Future<void> Function(TData? data, Object? error, TVariables variables, TContext? context)? onSettled,
int retryCount = 0,
Duration retryDelay = const Duration(seconds: 1),
List<Object>? invalidates,
})
Properties
onMutate
Future<TContext?> Function(TVariables variables)? onMutate
Called before the mutator. Returns TContext: the optimistic snapshot passed to onError/onSettled for rollback. If this callback throws, the mutator is not called and the controller transitions directly to MutationFailure.
onSuccess
Future<void> Function(TData data, TVariables variables, TContext? context)? onSuccess
Called after a successful mutator, before onSettled.
onError
Future<void> Function(Object error, TVariables variables, TContext? context)? onError
Called after the mutator throws (after all retries), before onSettled. Use context (the snapshot from onMutate) to roll back via client.restoreQueryData.
onSettled
Future<void> Function(TData? data, Object? error, TVariables variables, TContext? context)? onSettled
Called after every outcome: success or failure. data is non-null on success; error is non-null on failure.
retryCount
final int retryCount
Number of retry attempts after the initial failure. Default: 0 (no retry). Each retry uses exponential backoff: retryDelay * 2^attempt.
retryDelay
final Duration retryDelay
Base delay for the first retry. Default: Duration(seconds: 1). Subsequent retries double: 1s, 2s, 4s, …
invalidates
final List<Object>? invalidates
List of query keys to automatically invalidate after a successful mutation. Each key is passed to QoraClient.invalidate after onSuccess completes, triggering a refetch of any active queries whose key matches.
Replaces the common manual pattern:
// Before: manual invalidation inside onSuccess
onSuccess: (data, vars, _) async { client.invalidate(['posts']); }
// After: automatic via options
invalidates: [['posts']],
Works with offline queue replay too: queries are invalidated on successful replay, not on the initial optimistic emit.
Requires a QoraClient or an invalidateQuery callback wired via MutationController. Both QoraMutationBuilder and useMutation provide this automatically when a QoraScope ancestor is present.
invalidatesTags
final List<QueryTag>? invalidatesTags
Default: null
Tags to invalidate after a successful mutation. Every query that provides a matching tag is automatically refetched. Works alongside invalidates (which targets query keys directly) and is processed after it.
Wildcard matching applies: invalidating QueryTag('post') refetches ALL queries that provide any tag of type 'post', regardless of id.
// Refetch ALL queries providing a 'post' tag (any id).
MutationOptions(
invalidatesTags: [QueryTag('post')],
)
// Refetch only queries providing the specific 'post:123' tag.
MutationOptions(
invalidatesTags: [QueryTag('post', '123')],
)
Requires a QoraClient or an invalidateTags callback wired via MutationController. Both QoraMutationBuilder and useMutation provide this automatically when a QoraScope ancestor is present.
MutationState
Sealed class hierarchy representing all possible states of a mutation.
sealed MutationState
├── MutationIdle
├── MutationPending { variables }
├── MutationSuccess { data, variables }
└── MutationFailure { error, stackTrace?, variables }
Common Getters
Available on every variant:
| Getter | Type | Description |
|---|---|---|
isIdle | bool | true in MutationIdle |
isPending | bool | true in MutationPending |
isSuccess | bool | true in MutationSuccess |
isError | bool | true in MutationFailure |
dataOrNull | TData? | Non-null only in MutationSuccess |
errorOrNull | Object? | Non-null only in MutationFailure |
variablesOrNull | TVariables? | null only in MutationIdle |
when
void when({
void Function()? onIdle,
void Function(TVariables variables)? onPending,
void Function(TData data, TVariables variables)? onSuccess,
void Function(Object error, StackTrace? stackTrace, TVariables variables)? onError,
})
Call a callback for the matching variant. Unhandled variants are silently ignored.
maybeWhen
R maybeWhen<R>({
required R Function() orElse,
R Function()? onIdle,
R Function(TVariables)? onPending,
R Function(TData, TVariables)? onSuccess,
R Function(Object, StackTrace?, TVariables)? onError,
})
Returns the value from the matched callback, or orElse() if none matched.
fold (extension)
R fold<R>({
required R Function() onIdle,
required R Function(TVariables) onPending,
required R Function(TData, TVariables) onSuccess,
required R Function(Object, StackTrace?, TVariables) onError,
})
Exhaustive mapper: the compiler enforces that every variant is handled.
final label = state.fold(
onIdle: () => 'Create',
onPending: (_) => 'Creating…',
onSuccess: (post, _) => 'Created: ${post.title}',
onError: (err, _, __) => 'Error: $err',
);
status (extension)
MutationStatus get status
Returns the coarse-grained MutationStatus enum value: idle | pending | success | error.
MutationStatus
enum MutationStatus { idle, pending, success, error }
Coarse-grained status used by MutationUpdate to avoid generic type erasure issues at the event bus level.
MutationUpdate
Type-erased snapshot of a mutation state transition. Emitted by QoraClient.mutationEvents on every MutationController state change.
Properties
| Property | Type | Description |
|---|---|---|
mutatorId | String | Unique controller identifier (mutation_N) |
status | MutationStatus | Coarse-grained status at emission time |
data | Object? | Success data (type-erased). Non-null on success. |
error | Object? | Error. Non-null on failure. |
variables | Object? | Variables passed to mutate(). null when idle. |
timestamp | DateTime | When this event was emitted |
metadata | Map<String, Object?>? | Forwarded from MutationController.metadata |
Getters
| Getter | Type | Description |
|---|---|---|
isIdle | bool | status == MutationStatus.idle |
isPending | bool | status == MutationStatus.pending |
isSuccess | bool | status == MutationStatus.success |
isError | bool | status == MutationStatus.error |
isFinished | bool | isSuccess || isError: used by QoraClient to auto-purge activeMutations |
MutationTracker
Abstract interface implemented by QoraClient. Decouples MutationController from the client to avoid circular imports.
abstract interface class MutationTracker {
void trackMutation<TData, TVariables>(
String id,
MutationState<TData, TVariables> state, {
Map<String, Object?>? metadata,
});
void untrackMutation(String id);
}
Implement MutationTracker to build custom DevTools, logging middleware, or test spies:
class MutationLogger implements MutationTracker {
@override
void trackMutation<TData, TVariables>(
String id,
MutationState<TData, TVariables> state, {
Map<String, Object?>? metadata,
}) {
print('[$id] → ${state.runtimeType} ${metadata ?? ''}');
}
@override
void untrackMutation(String id) => print('[$id] disposed');
}
QoraClient: Mutation Observability
QoraClient implements MutationTracker and exposes two surfaces for observing tracked mutations.
mutationEvents
Stream<MutationUpdate> get mutationEvents
Real-time broadcast stream of every MutationUpdate from all tracked controllers. Emits on every state transition: including MutationIdle (reset) and MutationFailure.
client.mutationEvents.listen((event) {
if (event.isError) {
logger.error(
'[${event.mutatorId}] Mutation failed',
event.error,
event.metadata,
);
}
});
mutationEvents is a broadcast stream: events fired before your subscription are lost. To catch mutations that started before you subscribed, read activeMutations first.activeMutations
Map<String, MutationUpdate> get activeMutations
Unmodifiable snapshot of all currently running (pending) mutations, keyed by MutationController.id.
- Entry added when a controller transitions to
MutationPending - Entry auto-purged when the mutation finishes (
MutationSuccessorMutationFailure) - Entry removed when the controller resets to
MutationIdleor is disposed
// DevTools late-connect pattern:
// 1. Read snapshot to see what is currently in-flight
final pending = client.activeMutations;
// 2. Subscribe to stream for real-time updates (including completions)
client.mutationEvents.listen((event) {
devtools.push(event);
});
activeMutations never accumulates "ghost" entries for completed mutations: it is always a reliable "currently running" indicator.
debugInfo
QoraClient.debugInfo() includes mutation state in its output:
print(client.debugInfo());
// {
// total_queries: 5,
// active_queries: 2,
// inactive_queries: 3,
// pending_requests: 0,
// max_size: 100,
// active_mutations: 1, // ← currently pending mutations
// keys: [...]
// }
MutatorFunction
typedef MutatorFunction<TData, TVariables> =
Future<TData> Function(TVariables variables);
Type alias for the mutation function. Mirrors QueryFunction<T> for consistency with the fetcher/mutator naming convention throughout the API.
Next Steps
- Mutations Guide: Conceptual guide with all patterns
- QoraMutationBuilder: Flutter widget reference
- QoraClient: Full client API reference