Examples

Case Study: Optimistic Mutations & Rollback

A complete optimistic update implementation: setQueryData for immediate UI feedback, restoreQueryData for automatic rollback on failure, and invalidateWhere for server reconciliation.

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>: manages MutationController lifecycle (create on mount, dispose on unmount), wires offline queue automatically
  • MutationOptions.onMutate: captures a pre-mutation cache snapshot (TContext) and applies setQueryData before the server call
  • MutationOptions.onError: calls restoreQueryData with the TContext snapshot to revert both affected cache entries simultaneously
  • MutationOptions.onSuccess: calls invalidateWhere to 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 via TContext
  • MutationState pattern matching: four states (MutationIdle, MutationPending, MutationSuccess, MutationFailure) handled exhaustively with Dart 3 switch expressions
  • QoraBuilder + QoraMutationBuilder composition: 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:

lib/screens/user_list_screen.dart
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 success
  • TVariables = ({String id, String name}): input to the mutator
  • TContext = ({User? user, List<User>? list}): pre-mutation snapshot for rollback
lib/screens/rename_user_sheet.dart
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:

lib/screens/rename_user_sheet.dart
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:

lib/screens/rename_user_sheet.dart
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:

lib/screens/rename_user_sheet.dart
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:

lib/screens/rename_user_sheet.dart
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:

  1. Open User List and wait for the initial load
  2. Tap the edit icon on any user
  3. Enter a new name and tap Save: the list updates immediately in the background
  4. 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
  5. 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

View full source code


Copyright © 2026