Flutter Integration

QoraMutationBuilder

The widget that manages a MutationController lifecycle and rebuilds your UI on every mutation state transition.

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

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

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

PropTypeDefaultDescription
mutatorFuture<TData> Function(TVariables)requiredAsync write function. Named mutator to mirror fetcher.
builderWidget Function(context, MutationState, mutate)requiredBuilds the widget tree from the current state.
optionsMutationOptions<TData, TVariables, TContext>?nullHooks, retry, offline queue, and optimistic response config.
metadataMap<String, Object?>?nullKey-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 sealed MutationState. Pattern-match or use .isPending, .isSuccess, .isError helpers.
  • mutate(variables): triggers the mutation. Returns TData on success or null on failure. Errors never propagate to the caller; they appear as MutationFailure state instead.

Lifecycle Hooks

Use options to perform side-effects around the mutation:

settings_screen.dart
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),
    );
  },
)
Capturing 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:

lib/ui/todo_screen.dart
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:

lib/ui/create_post_screen.dart
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:

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

create_post_screen.dart
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'),
    );
  },
)
If you need programmatic 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:

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

onboarding_screen.dart
// 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, or metadata identity changes (didUpdateWidget)
  • Disposed on widget unmount (dispose)

When the controller is recreated, the mutation state resets to MutationIdle.


Next Steps

Copyright © 2026