Flutter Query

Flutter Query

Mutations

How mutations modify server data and update your UI

Queries fetch data. Mutations change it.

Where a query declares "I need this data," a mutation declares "I want to change something." Creating a new todo, updating a user's profile, deleting a record. These are all mutations. They represent actions that modify server state rather than read it.

How mutations differ from queries

Queries and mutations solve fundamentally different problems. Understanding this distinction shapes how you use them.

A query runs automatically. When a widget mounts and requests data, the query springs into action. It fetches, caches, and updates as needed. The widget declares what it needs, and the query handles when and how to get it.

A mutation waits. It doesn't execute until you explicitly tell it to. This makes sense because you don't want to accidentally delete a user's account every time a component renders. You call mutate() when the user clicks a button, submits a form, or takes some deliberate action.

This difference extends to caching. Queries cache their results, sharing data across widgets and persisting it for reuse. Mutations don't work this way. You might use a mutation's result to update the query cache, but mutations themselves are fire-and-forget operations.

When to use mutations

Use a mutation when you need to:

  • Create new data on the server
  • Update existing data
  • Delete data
  • Trigger server-side effects (sending emails, processing jobs)
  • Perform any operation that changes state in database

In terms of HTTP methods, mutations typically wrap POST, PUT, PATCH, or DELETE requests. If you're making a GET request, that's usually a query.

If you're fetching data to display, that's a query. If you're changing data in response to user action, that's a mutation.

Mutation status

Every mutation exists in one of four states:

  • Idle: The mutation hasn't been triggered yet, or has been reset. This is the starting state.
  • Pending: The mutation is currently executing. The network request is in flight.
  • Success: The mutation completed successfully. The result data is available.
  • Error: The mutation failed. The error is available.

Unlike queries, which start in pending and immediately begin fetching, mutations start in idle and stay there until you call mutate().

final mutation = useMutation(...);

if (mutation.isIdle) {
  // Ready to mutate, hasn't been triggered yet
}
if (mutation.isPending) {
  // Currently executing
}
if (mutation.isSuccess) {
  // Completed successfully, mutation.data is available
}
if (mutation.isError) {
  // Failed, mutation.error is available
}

After a mutation completes (success or error), it stays in that state until you call reset() to return it to idle, or trigger another mutation.

Mutation callbacks

The useMutation hook accepts four optional callbacks that run at different points during the mutation:

onMutate runs before the mutation function executes. This is where you can perform optimistic updates. Whatever value you return from onMutate gets passed to the other callbacks. You can type this return value using the fourth generic parameter of useMutation.

onSuccess runs after the mutation completes successfully. You receive the data returned by the mutation function, the variables you passed to mutate(), and the value from onMutate. This is the natural place to invalidate related queries or update the cache with the server's response.

onError runs if the mutation fails. You receive the error, the variables, and the value from onMutate. If you performed an optimistic update, this is where you roll it back using the previous state you stored.

onSettled runs after the mutation completes, regardless of whether it succeeded or failed. You receive the data (if successful), the error (if failed), the variables, and the value from onMutate. Use this for cleanup logic that should happen either way.

final mutation = useMutation<Todo, Exception, CreateTodoInput, String>(
  (input, context) => api.createTodo(input),
  onMutate: (variables, context) {
    return 'Hello from onMutate';
  },
  onSuccess: (data, variables, onMutateResult, context) {
    print(onMutateResult); // 'Hello from onMutate'
    print('Created todo: $data');
  },
  onError: (error, variables, onMutateResult, context) {
    print(onMutateResult); // 'Hello from onMutate'
    print('Failed: $error');
  },
  onSettled: (data, error, variables, onMutateResult, context) {
    print(onMutateResult); // 'Hello from onMutate'
    print('Mutation finished');
  },
);

Why mutations don't retry by default

Queries retry failed requests three times with exponential backoff. Mutations don't retry at all by default. This asymmetry is deliberate.

A query is idempotent, so fetching the same data twice produces the same result. Retrying a failed query is safe. But mutations often aren't idempotent. If a "create user" request times out, did it succeed or fail? Retrying might create duplicate users. If a "transfer money" request fails ambiguously, retrying might transfer the money twice.

You can enable retry explicitly for mutations that are safe to repeat, like updating a status or setting a preference. The default is conservative because blindly retrying a mutation could produce an unexpected result.

Optimistic updates

Sometimes waiting for the server feels slow. When a user toggles a checkbox, they expect immediate feedback, not a spinner followed by the checkbox changing.

Optimistic updates show the expected result before the server confirms it. The onMutate callback runs before the mutation function, letting you update the UI immediately. If the mutation succeeds, the optimistic update was correct. If it fails, you roll back to the previous state.

The value you return from onMutate is passed to onError and onSettled, making rollback straightforward. Store the previous state in onMutate, and use it to restore the cache if things go wrong.

This pattern trades correctness guarantees for perceived performance. The UI shows something that might not be true yet. For many interactions like liking a post or marking a task complete, this tradeoff makes the app feel dramatically more responsive.

On this page