QoraMutationBuilder
QoraMutationBuilder<TData, TVariables, TContext> creates a MutationController on mount, disposes it on unmount, and rebuilds whenever the mutation state changes. The builder callback receives the current MutationState and a mutate(variables) callback to trigger the write from the UI.
QoraMutationBuilder does not require a QoraScope ancestor. It wires itself to the client automatically when one is present, and falls back to standalone mode gracefully when one is not.Basic Usage
QoraMutationBuilder<Post, String, void>(
mutator: (title) => api.createPost(title),
builder: (context, state, mutate) {
return ElevatedButton(
onPressed: state.isPending ? null : () => mutate('New Post'),
child: state.isPending
? const CircularProgressIndicator()
: const Text('Create'),
);
},
)
The button is disabled while the mutation is running. Pass null to onPressed: Flutter disables the button automatically.
State-Driven UI
Pattern-match directly on the sealed MutationState for exhaustive UI coverage:
QoraMutationBuilder<Post, String, void>(
mutator: (title) => api.createPost(title),
builder: (context, state, mutate) {
return switch (state) {
MutationIdle() => SubmitButton(onTap: () => mutate('Post')),
MutationPending() => const CircularProgressIndicator(),
MutationSuccess(:final data) => SuccessBanner('Created: ${data.title}'),
MutationFailure(:final error) => Column(children: [
ErrorBanner(error),
TextButton(
onPressed: () => mutate('Post'), // Retry
child: const Text('Try again'),
),
]),
};
},
)
Props
| Prop | Type | Default | Description |
|---|---|---|---|
mutator | Future<TData> Function(TVariables) | required | Async write function. Named mutator to mirror fetcher. |
builder | Widget Function(context, MutationState, mutate) | required | Builds the widget tree from the current state. |
options | MutationOptions<TData, TVariables, TContext>? | null | Hooks, retry, offline queue, and optimistic response config. |
metadata | Map<String, Object?>? | null | Key-value pairs forwarded to every MutationEvent. |
The builder Callback
Widget Function(
BuildContext context,
MutationState<TData, TVariables> state,
Future<TData?> Function(TVariables variables) mutate,
)
state: the current sealedMutationState. Pattern-match or use.isPending,.isSuccess,.isErrorhelpers.mutate(variables): triggers the mutation. ReturnsTDataon success ornullon failure. Errors never propagate to the caller; they appear asMutationFailurestate instead.
Lifecycle Hooks
Use options to perform side-effects around the mutation:
QoraMutationBuilder<void, UserUpdate, void>(
mutator: (update) => api.updateUser(update),
options: MutationOptions(
invalidates: [['users', currentUserId]], // Auto-refresh on success
onSuccess: (_, update, __) async {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Settings saved')),
);
},
onError: (error, _, __) async {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $error')),
);
},
),
builder: (context, state, mutate) {
return SaveButton(
isLoading: state.isPending,
onTap: () => mutate(formData),
);
},
)
context inside onSuccess/onError is safe as long as the widget is still mounted. Prefer using ScaffoldMessenger.of(context): it is resilient to widget tree changes. Avoid Navigator.of(context) in async callbacks without a mounted check.Offline Mutation Queue
When the device is offline, mutations with offlineQueue: true are enqueued in FIFO order and replayed automatically when connectivity returns. Provide optimisticResponse to give instant feedback even without a server response:
QoraMutationBuilder<void, Todo, void>(
mutator: (todo) => api.createTodo(todo),
options: MutationOptions(
invalidates: [['todos']], // Auto-refresh on success or offline replay
// Opt into the offline queue
offlineQueue: true,
// Provide an optimistic TData so the UI updates immediately
optimisticResponse: (todo) => null,
),
builder: (context, state, mutate) {
return AddTodoButton(
onTap: () => mutate(newTodo),
);
},
)
isOptimistic: styling unconfirmed items
When a mutation is queued offline and optimisticResponse is provided, the state transitions to MutationSuccess(isOptimistic: true). Use the isOptimistic flag to apply a pending visual (greyed out, clock icon, strikethrough) so users know the item is not yet confirmed by the server:
QoraMutationBuilder<Post, String, void>(
mutator: api.createPost,
options: MutationOptions(
offlineQueue: true,
optimisticResponse: (title) => Post.optimistic(title),
),
builder: (context, state, mutate) {
// Extract isOptimistic from the success state
final isOptimistic = switch (state) {
MutationSuccess(:final isOptimistic) => isOptimistic,
_ => false,
};
return Column(
children: [
if (isOptimistic)
const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.schedule, size: 14, color: Colors.grey),
SizedBox(width: 4),
Text('Queued: syncing when online',
style: TextStyle(color: Colors.grey)),
],
),
ElevatedButton(
onPressed: state.isPending ? null : () => mutate('New Post'),
child: const Text('Create'),
),
],
);
},
)
isOptimistic is also available on MutationState directly:
// On the sealed class
state.isOptimistic // false for all states except MutationSuccess with isOptimistic: true
// Inside when()
state.when(
onSuccess: (data, variables, isOptimistic) {
if (isOptimistic) showPendingIndicator();
else showConfirmedBadge();
},
...
)
isOptimistic: true is emitted only when offlineQueue: trueandoptimisticResponse is provided and the device is offline. A real server success always produces isOptimistic: false.Optimistic Updates
Combine onMutate (optimistic write) and onError (rollback) to give instant feedback while the server confirms the change:
QoraMutationBuilder<void, Todo, List<Todo>?>(
// TContext = List<Todo>?: the snapshot for rollback
mutator: (todo) => api.addTodo(todo),
options: MutationOptions(
invalidates: [['todos']], // 4️⃣ Pull confirmed data from server
onMutate: (todo) async {
// 1️⃣ Snapshot current list
final prev = context.qora.getQueryData<List<Todo>>(['todos']);
// 2️⃣ Apply optimistically: UI updates before the network call
context.qora.setQueryData<List<Todo>>(
['todos'],
[...?prev, todo.copyWith(isOptimistic: true)],
);
return prev; // Returned as TContext
},
onError: (_, __, prev) async {
// 3️⃣ Rollback on failure
context.qora.restoreQueryData(['todos'], prev);
},
),
builder: (context, state, mutate) {
return AddTodoButton(
isLoading: state.isPending,
onTap: () => mutate(newTodo),
);
},
)
context.qora is the shorthand for QoraScope.of(context): available when the widget is inside a QoraScope.
Reset
After displaying a success or failure result, call reset() to return to MutationIdle and allow the user to trigger another mutation.
Because QoraMutationBuilder owns the controller internally, you can drive reset through external widget state:
QoraMutationBuilder<Post, String, void>(
mutator: (title) => api.createPost(title),
builder: (context, state, mutate) {
if (state is MutationSuccess<Post, String>) {
return Column(children: [
Text('Created: ${state.data.title}'),
TextButton(
// Rebuilds with a fresh idle state on next frame
onPressed: () => setState(() {}),
child: const Text('Create another'),
),
]);
}
return SubmitButton(
onTap: state.isPending ? null : () => mutate('My Post'),
);
},
)
reset() or mutate() access outside the builder, consider using MutationController directly and subscribing to its stream in a StatefulWidget.DevTools Labelling with metadata
Attach domain context to every MutationEvent for easier DevTools filtering:
QoraMutationBuilder<User, LoginCredentials, void>(
mutator: authApi.login,
metadata: {'category': 'auth', 'screen': 'login'},
builder: (context, state, mutate) { ... },
)
All events emitted by this controller will include metadata: {'category': 'auth', 'screen': 'login'}, visible in client.mutationEvents and client.activeMutations.
Standalone Mode
QoraMutationBuilder is safe to use anywhere in the widget tree: even without a QoraScope ancestor. In that case, tracker is null and the controller operates in standalone mode with no global DevTools wiring:
// No QoraScope required
QoraMutationBuilder<void, OnboardingData, void>(
mutator: onboardingApi.submit,
builder: (context, state, mutate) { ... },
)
Controller Lifecycle
The internal MutationController is:
- Created on widget mount (
initState) - Recreated when
mutator,options, ormetadataidentity changes (didUpdateWidget) - Disposed on widget unmount (
dispose)
When the controller is recreated, the mutation state resets to MutationIdle.
Next Steps
- Network Aware Guide: Offline queue,
isOptimistic, and reconnect replay in full detail - Mutations Guide: Full conceptual guide with all patterns
- Optimistic Updates: Deep dive into rollback
- Mutation API Reference: Full API documentation