Mutations
A mutation is any operation that changes server-side state, including create, update, and delete operations. Queries are read-only and cache-driven. Mutations are explicit write operations.
Qora manages mutations with MutationController, a standalone lifecycle controller with no cache key, no deduplication, and no SWR behavior.
The State Machine
MutationIdle
│
│ mutate(variables)
▼
MutationPending
│
├── success ──▶ MutationSuccess
│
└── failure ──▶ MutationFailure
│
└── reset() ──▶ MutationIdle
Every state carries variables: the value that was passed to mutate(). This makes it easy to display "Creating post…" or "Failed to delete comment #42" without extra local state.
Basic Usage
final controller = MutationController<Post, String, void>(
mutator: (title) => api.createPost(title),
);
// Trigger the mutation
final post = await controller.mutate('Hello, Qora!');
// Returns the Post on success, or null on failure.
// Observe state transitions
controller.stream.listen((state) {
switch (state) {
case MutationIdle():
print('Ready');
case MutationPending(:final variables):
print('Creating "$variables"…');
case MutationSuccess(:final data):
print('Created: ${data.title}');
case MutationFailure(:final error):
print('Failed: $error');
}
});
// Reset to idle after showing the result
controller.reset();
// Always dispose when done
controller.dispose();
mutate() never throws. Errors are captured in MutationFailure state and returned as null from the call site.
Lifecycle Callbacks
MutationOptions provides four hooks that run around the mutation:
final controller = MutationController<Post, String, void>(
mutator: (title) => api.createPost(title),
options: MutationOptions(
invalidates: [['posts']], // Auto-refresh the posts list on success
onMutate: (title) async {
print('About to create "$title"');
// Return value becomes TContext: passed to onSuccess, onError, onSettled.
},
onSuccess: (post, title, _) async {
print('Created: ${post.id}');
},
onError: (error, title, _) async {
print('Failed to create "$title": $error');
},
onSettled: (post, error, title, _) async {
// Called regardless of outcome.
analytics.track('post_create_attempt', {'success': error == null});
},
),
);
| Hook | When | Receives |
|---|---|---|
onMutate(variables) | Before the mutator starts | TVariables |
onSuccess(data, variables, context) | After a successful mutator | TData, TVariables, TContext? |
onError(error, variables, context) | After the mutator throws | Object, TVariables, TContext? |
onSettled(data, error, variables, context) | After either outcome | TData?, Object?, TVariables, TContext? |
The value returned by onMutate becomes TContext: the third type parameter of MutationController. This is the mechanism behind optimistic update rollback.
Automatic Invalidation
Instead of calling client.invalidate() manually inside onSuccess, declare the query keys upfront with invalidates:
options: MutationOptions(
invalidates: [['posts'], ['users', userId]],
)
Each key is invalidated automatically after the mutation succeeds, whether it runs immediately or is replayed from the offline queue. Invalidations happen after onSuccess completes, so the cache is freshened after any manual side-effects.
// Before: manual
onSuccess: (_, __, ___) async { client.invalidate(['posts']); }
// After: automatic
invalidates: [['posts']],
Tag-based invalidation
You can also invalidate queries by tag, completely decoupling from query-key structure:
options: MutationOptions(
invalidatesTags: [QueryTag('post'), QueryTag('user', userId)],
)
Every query whose QoraOptions.providesTags includes a matching tag is automatically refetched. Wildcard matching: QueryTag('post') refetches all queries providing any 'post' tag regardless of id. Specific matching: QueryTag('post', '123') refetches only queries providing exactly 'post':'123'.
// Before: key-based
invalidates: [['posts']],
// After: tag-based
invalidatesTags: [QueryTag('post')],
Works with QoraMutationBuilder, useMutation, and raw MutationController as long as a QoraClient or invalidateQuery callback is wired. Both invalidates and invalidatesTags can be used together; tag invalidation runs after key-based invalidation.
Optimistic Updates
Optimistic updates apply a change to the local cache immediately: before the server confirms it. If the server rejects the change, the previous data is restored.
The pattern has three steps:
onMutate: snapshot the current cache data, apply the optimistic change, return the snapshot asTContextonError: use the snapshot (TContext) to roll back viaclient.restoreQueryData()onSuccess: invalidate the query to pull the confirmed server data
final controller = MutationController<Post, String, List<Post>?>(
// TContext = List<Post>?: the snapshot we'll use for rollback
mutator: (title) => api.createPost(title),
options: MutationOptions(
invalidates: [['posts']], // 4️⃣ Confirm: replace optimistic data with real server data
onMutate: (title) async {
// 1️⃣ Snapshot
final previous = client.getQueryData<List<Post>>(['posts']);
// 2️⃣ Optimistic update: UI reflects change immediately
client.setQueryData<List<Post>>(
['posts'],
[...?previous, Post.optimistic(title)],
);
return previous; // Becomes TContext
},
onError: (error, title, previous) async {
// 3️⃣ Rollback: restore the snapshot on failure
client.restoreQueryData(['posts'], previous);
},
),
);
await controller.mutate('New Post');
client.restoreQueryData(key, null) removes the query from the cache entirely: useful when the snapshot was null (the list didn't exist yet).Handling onMutate Failure
If onMutate throws (e.g. a pre-flight validation fails), the mutator is not called. The controller transitions directly to MutationFailure.
options: MutationOptions(
onMutate: (title) async {
if (title.isEmpty) throw ArgumentError('Title cannot be empty');
// Mutator will never run if this throws
return null;
},
),
This is useful for client-side validation that should block the network call entirely.
Retry
By default mutations do not retry (unlike queries, retrying a write can have side effects). Enable retry explicitly:
options: const MutationOptions(
retryCount: 2, // 1 initial + 2 retries = 3 total attempts
retryDelay: Duration(seconds: 1), // Exponential backoff: 1s, 2s
),
Retry only applies to the mutator. If onMutate throws, no retry occurs.
Standalone Mode
MutationController works without any QoraClient. Pass tracker: null (the default) to operate in standalone mode with no global observability:
// No client required: mutations work anywhere
final controller = MutationController<Order, Cart, void>(
mutator: (cart) => paymentApi.checkout(cart),
);
DevTools Observability
Wire a controller into QoraClient by passing tracker: client. The client then exposes all mutation activity via mutationEvents and activeMutations.
final controller = MutationController<Post, String, void>(
mutator: (title) => api.createPost(title),
tracker: client,
// Optional: label this controller for DevTools filtering
metadata: {'category': 'content', 'resource': 'post'},
);
// Listen to all mutation activity across all tracked controllers
client.mutationEvents.listen((event) {
if (event.isError) {
logger.error('[${event.mutatorId}] Failed', event.error);
}
});
// Snapshot of currently running mutations (only Pending entries)
final running = client.activeMutations;
print('${running.length} mutation(s) in flight');
activeMutations only contains MutationPending entries: finished mutations (Success or Failure) are auto-purged. This prevents memory accumulation and makes the map a reliable "currently in-flight" indicator.When using QoraMutationBuilder in Flutter, tracker is set to QoraScope.maybeOf(context) automatically: no manual wiring needed.
Multi-Query Optimistic Updates
A single mutation can touch multiple queries. Each setQueryData call triggers a reactive update in any QoraBuilder subscribed to that key:
options: MutationOptions(
onMutate: (postId) async {
// Snapshot both queries
final posts = client.getQueryData<List<Post>>(['posts']);
final user = client.getQueryData<User>(['users', currentUserId]);
// Update both optimistically
client.setQueryData<List<Post>>(
['posts'],
posts?.where((p) => p.id != postId).toList(),
);
client.setQueryData<User>(
['users', currentUserId],
user?.copyWith(postCount: (user.postCount ?? 1) - 1),
);
return (posts: posts, user: user); // TContext is a record
},
onError: (error, postId, snapshots) async {
// Roll back both
client.restoreQueryData(['posts'], snapshots?.posts);
client.restoreQueryData(['users', currentUserId], snapshots?.user);
},
),
Next Steps
- MutationBuilder: Bind mutations to Flutter UI
- Optimistic Updates: Deep dive into rollback patterns
- Mutation API Reference: Full API documentation