Recipes

Mutations

Perform write operations (POST, PUT, DELETE) with full lifecycle support: optimistic updates, automatic rollback, retry, and DevTools observability.

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

post_repository.dart
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:

post_repository.dart
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});
    },
  ),
);
HookWhenReceives
onMutate(variables)Before the mutator startsTVariables
onSuccess(data, variables, context)After a successful mutatorTData, TVariables, TContext?
onError(error, variables, context)After the mutator throwsObject, TVariables, TContext?
onSettled(data, error, variables, context)After either outcomeTData?, 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:

  1. onMutate: snapshot the current cache data, apply the optimistic change, return the snapshot as TContext
  2. onError: use the snapshot (TContext) to roll back via client.restoreQueryData()
  3. onSuccess: invalidate the query to pull the confirmed server data
post_repository.dart
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.

post_repository.dart
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:

post_repository.dart
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:

checkout_service.dart
// 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.

post_repository.dart
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:

post_repository.dart
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

Copyright © 2026