Case Study: Optimistic Mutations & Rollback
Objective
Context: Write operations introduce a latency problem. A round-trip to the server takes 1–3 seconds. Blocking the UI until the server responds produces sluggish interactions and breaks the perceived responsiveness of the application.
Optimistic updates resolve this by applying the expected result to the local cache immediately, before the server responds. If the server confirms the operation, the optimistic data is replaced by the authoritative server response. If the server rejects it, the cache is restored to its pre-mutation state.
This example implements the full optimistic update protocol using QoraMutationBuilder, setQueryData, and restoreQueryData. The simulated API fails ~30 % of the time to make rollback observable.
Key Features Demonstrated
QoraMutationBuilder<TData, TVariables, TContext>: managesMutationControllerlifecycle (create on mount, dispose on unmount), wires offline queue automaticallyMutationOptions.onMutate: captures a pre-mutation cache snapshot (TContext) and appliessetQueryDatabefore the server callMutationOptions.onError: callsrestoreQueryDatawith theTContextsnapshot to revert both affected cache entries simultaneouslyMutationOptions.onSuccess: callsinvalidateWhereto trigger background refetch and reconcile the cache with the server-confirmed state- Cross-cache optimistic update: a single mutation updates two independent cache entries (
['users', id]and['users']) atomically viaTContext MutationStatepattern matching: four states (MutationIdle,MutationPending,MutationSuccess,MutationFailure) handled exhaustively with Dart 3 switch expressionsQoraBuilder+QoraMutationBuildercomposition: read and write operations coexist on the same screen without shared mutable state
Architecture Highlights
The mutation lifecycle is entirely managed by QoraMutationBuilder. The widget tree declares intent; Qora coordinates cache, network, and state transitions:
ex_02_mutations_optimistic/lib/
├── main.dart ← QoraClient + QoraScope at root
├── models/
│ └── user.dart ← pure data class with copyWith
├── services/
│ └── fake_api.dart ← async API (2 s latency, ~30 % failure on updateUser)
└── screens/
├── user_list_screen.dart ← QoraBuilder<List<User>>, key: ['users']
└── rename_user_sheet.dart ← QoraMutationBuilder + three-step optimistic protocol
QoraClient is configured once at the root. UserListScreen reads the user list via QoraBuilder. The edit action captures context.qora and passes it explicitly to RenameUserSheet, since modal routes run in a separate navigator overlay:
Future<void> _showRenameSheet(BuildContext context, User user) {
// Capture the client before the sheet opens: modal routes run in a
// separate navigator overlay and may not resolve QoraScope on their own.
final client = context.qora;
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (_) => RenameUserSheet(user: user, client: client),
);
}
Implementation Details
The three-step optimistic update protocol
Every optimistic mutation follows three sequential hooks defined in MutationOptions. The type parameters encode the domain types precisely:
TData = User: value returned by the server on successTVariables = ({String id, String name}): input to the mutatorTContext = ({User? user, List<User>? list}): pre-mutation snapshot for rollback
QoraMutationBuilder<User, _UpdateVars, _Snapshot>(
mutator: (vars) => FakeApi.updateUser(vars.id, name: vars.name),
options: MutationOptions<User, _UpdateVars, _Snapshot>(
onMutate: ..., // Step 1: snapshot + setQueryData
onError: ..., // Step 2: restoreQueryData (rollback)
onSuccess: ..., // Step 3: invalidateWhere (reconciliation)
),
builder: (context, state, mutate) { ... },
)
The hooks execute in sequence: onMutate runs before the mutator; onError or onSuccess run after it settles. The TContext value returned by onMutate is forwarded to both onError and onSuccess as the third parameter.
Cross-cache atomic snapshot
onMutate captures two independent cache entries before modifying either. The Dart 3 record type ({User? user, List<User>? list}) carries both snapshots as a single typed value:
onMutate: (vars) async {
// Capture the current data for both cache entries before mutating.
final prevUser = client.getQueryData<User>(['users', vars.id]);
final prevList = client.getQueryData<List<User>>(['users']);
// Apply the optimistic name change to the per-user detail cache.
if (prevUser != null) {
client.setQueryData<User>(
['users', vars.id],
prevUser.copyWith(name: vars.name),
);
}
// Apply the same change to the matching entry inside the list cache.
if (prevList != null) {
client.setQueryData<List<User>>(
['users'],
prevList
.map((u) => u.id == vars.id ? u.copyWith(name: vars.name) : u)
.toList(),
);
}
// Return both snapshots as the rollback context (TContext).
return (user: prevUser, list: prevList);
},
setQueryData pushes the new value to all active QoraBuilder streams subscribed to the matching key. Both UserListScreen and any open detail widget receive the update on the next frame, before the network call completes.
Automatic rollback on failure
onError receives the TContext snapshot returned by onMutate. restoreQueryData is a no-op when the snapshot is null, so null-safety is preserved without defensive branching:
onError: (error, vars, ctx) async {
// Restore both cache entries from the snapshot returned by onMutate.
client.restoreQueryData<User>(['users', vars.id], ctx?.user);
client.restoreQueryData<List<User>>(['users'], ctx?.list);
},
When the server rejects the mutation, both cache entries revert to their pre-mutation values in a single synchronous operation. All subscribed widgets rebuild immediately, displaying the original data without any user intervention.
Server reconciliation on success
onSuccess uses invalidateWhere to mark all user-related cache entries as stale. Qora triggers background refetches for every active subscriber, replacing the optimistic data with the server-confirmed state:
onSuccess: (user, vars, _) async {
// Trigger a background refetch for all user-related entries
// so the cache reflects the server-confirmed state.
client.invalidateWhere((key) => key.firstOrNull == 'users');
},
The predicate matches both ['users'] and ['users', id] simultaneously. Active subscribers transition to Loading(previousData: data): the optimistic data remains visible during the background fetch, and the final server response replaces it without a blank-screen transition.
MutationState pattern matching
QoraMutationBuilder rebuilds whenever the MutationController emits a new state. The builder maps all four states to UI:
builder: (context, state, mutate) {
return Column(
children: [
TextField(
controller: _nameController,
enabled: !state.isPending, // disable input while the server call is in flight
...
),
// Inline status feedback via Dart 3 switch expression
switch (state) {
MutationFailure(:final error) => _FeedbackBanner(
icon: Icons.undo_rounded,
message: 'Save failed, name restored. $error',
color: Colors.red,
),
MutationSuccess() => const _FeedbackBanner(
icon: Icons.check_circle_outline,
message: 'Saved. Cache updated from server.',
color: Colors.green,
),
_ => const SizedBox.shrink(), // MutationIdle, MutationPending
},
FilledButton(
onPressed: state.isPending ? null : () => mutate((id: user.id, name: name)),
child: state.isPending
? const CircularProgressIndicator(strokeWidth: 2.5)
: const Text('Save'),
),
],
);
},
MutationIdle and MutationPending share the _ arm: the feedback banner is absent and the button is enabled or disabled by state.isPending. MutationFailure and MutationSuccess each render a distinct inline banner. No boolean flags, no setState calls.
Validation Steps
The example ships with an observable failure mode to make rollback visible:
- Open User List and wait for the initial load
- Tap the edit icon on any user
- Enter a new name and tap Save: the list updates immediately in the background
- Wait ~2 s for the server response:
- On success (~70 %): the new name persists; a green confirmation appears
- On failure (~30 %): the name reverts to the original; a red rollback banner appears
- Tap the refresh icon in the app bar to force a full invalidation and observe the server-confirmed state
Source Code
examples/ex_02_mutations_optimistic/
├── lib/
│ ├── main.dart ← QoraClient + QoraScope setup
│ ├── models/user.dart ← pure data class with copyWith
│ ├── services/fake_api.dart ← simulated API (2 s delay, ~30 % failure on updateUser)
│ └── screens/
│ ├── user_list_screen.dart ← QoraBuilder + edit trigger
│ └── rename_user_sheet.dart ← QoraMutationBuilder + full optimistic protocol
└── pubspec.yaml
Related
- Optimistic Updates Recipe: step-by-step conceptual walkthrough of the same pattern
- Mutations Recipe:
QoraMutationBuilderandMutationOptionsAPI reference - QoraMutationBuilder: full widget API
- Case Study: User List & Detail:
QoraBuilder, SWR caching, andFetchStatus
Case Study: User List & Detail
A complete list-and-detail feature: SWR caching, FetchStatus two-axis state, per-item query keys, pull-to-refresh, and graceful degradation. Zero boilerplate state management.
Case Study: Infinite Scroll Feed
Build a high-performance social media-style feed with cursor-based pagination, windowed memory management, and instant optimistic post creation.