In Riverpod 3, what are the experimental mutations, and how would you show a spinner on a news article's bookmark button while the save runs?
answer
- experimental import path
- Mutation<T>() in a final variable
- idle, pending, success, error
- run(ref, (tsx) async ...)
- keyed per article with call
basics
~20 sMutations are an experimental Riverpod 3 API for tracking a side effect's progress outside provider state. A Mutation keyed per article is watched for MutationIdle/Pending/Success/Error and started with run(ref, (tsx) async ...), so the button shows a spinner while pending.
solid answer
~40 sImported from `package:flutter_riverpod/experimental/mutation.dart`, a `Mutation<T>()` stored in a final variable represents one kind of operation, such as bookmarking. `ref.watch(bookmarkArticle(articleId))` - calling the mutation with a key, compared by `==` - gives a `MutationState`: `MutationIdle`, `MutationPending`, `MutationSuccess` (with `value`) or `MutationError` (with `error`), all subclasses of a sealed type. `bookmarkArticle(articleId).run(ref, (tsx) async {...})` sets pending, runs the callback, then sets success or error; `tsx.get(provider)` reads providers and keeps them alive until the mutation finishes. `run` also rethrows the error, so the caller should catch it. `reset(ref)` returns it to idle, and the state is disposed once nothing listens. The point is to keep 'is the button busy' out of the feed's own state. Being experimental, the API may change without a major version bump.
code
dart · 36 linesimport 'package:flutter/material.dart';
import 'package:flutter_riverpod/experimental/mutation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
final bookmarkArticle = Mutation<void>();
class BookmarkButton extends ConsumerWidget {
const BookmarkButton({super.key, required this.articleId});
final String articleId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final bookmark = bookmarkArticle(articleId);
final state = ref.watch(bookmark);
return switch (state) {
MutationPending() => const SizedBox.square(
dimension: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
_ => IconButton(
icon: Icon(state is MutationError ? Icons.error_outline : Icons.bookmark_add),
onPressed: () async {
try {
await bookmark.run(ref, (tsx) async {
await tsx.get(bookmarksRepositoryProvider).add(articleId);
});
} on Exception {
// Already recorded as MutationError; the icon reflects it.
}
},
),
};
}
}go deeper
Know that mutations track the progress of a user action with idle, pending, success and error states, and that they are experimental.
Explain keying with call, run with a transaction, tsx.get keeping providers alive, rethrown errors and reset.
Decide when UI progress belongs in a mutation rather than notifier state, and contain experimental API usage behind a small surface.
Weigh adopting an experimental API against its churn risk, and set a policy for experimental dependencies across the codebase.
## The problem mutations address A news feed provider holds articles. When the user taps **bookmark** on one article, the UI wants a spinner on that button, then a filled icon or an error. Without mutations you would add `isBookmarking` flags - per article - to the feed's state, mixing UI progress into domain data and writing loading, error and success handling by hand. **Mutations** track the progress of such an operation separately. They are **experimental**: the documentation warns the API may change in a breaking way without a major version bump, and they live behind a dedicated import, `package:flutter_riverpod/experimental/mutation.dart` (or `package:riverpod/experimental/mutation.dart` in pure Dart). ## Defining and keying ```dart final bookmarkArticle = Mutation<void>(); ``` A mutation is an object in a final variable - global, or `static final` on a notifier. Calling it with a key gives an independent instance per key: `bookmarkArticle(articleId)`. Keys are compared with `==`, so use ids, records or value-equality objects. ## Listening A `Mutation` is a `ProviderListenable<MutationState<T>>`, so `ref.watch` works: | State | Meaning | Data | |---|---|---| | `MutationIdle` | never run, or reset | - | | `MutationPending` | `run` in progress | - | | `MutationSuccess` | completed | `value` of type `T` | | `MutationError` | callback threw | `error` | `MutationState` is sealed, so a `switch` over it is exhaustive; there are also `isIdle`, `isPending`, `isSuccess` and `hasError` getters. ## Triggering ```dart await bookmarkArticle(articleId).run(ref, (tsx) async { await tsx.get(bookmarksRepositoryProvider).add(articleId); }); ``` 1. The state becomes `MutationPending`. 2. The callback runs. `tsx.get(provider)` reads a provider **and keeps it alive until the mutation completes**, so an autoDispose repository is not disposed mid-save. 3. On success, `MutationSuccess(value)`; on a throw, `MutationError(error)`. 4. `run` returns the callback's result - and on failure **rethrows** the error after recording it. In an `onPressed`, wrap the `await` in `try`/`catch`, or the failure also surfaces as an uncaught error. Both `WidgetRef` and `Ref` can be passed to `run` and `reset`. ## Resetting - `bookmarkArticle(articleId).reset(ref)` returns the instance to `MutationIdle`. - The mutation's state is itself auto-disposed: once nothing listens - the button scrolls away - it goes back to idle. ## Where mutations fit - They do **not** replace `AsyncNotifier` methods. The notifier still owns the data and the logic. - They answer 'what is the progress of this user-triggered operation?' for the UI, keyed per target. - Because the API is experimental, a team adopting it should isolate its use - a helper or a small set of widgets - so a breaking change is cheap to absorb. ## Mutations versus AsyncValue - `AsyncValue` describes the state of **data a provider owns**: the feed, a profile. - `MutationState` describes the progress of **one user-triggered operation**: saving, deleting, bookmarking. - A successful mutation typically also updates or invalidates the provider that owns the data, so the feed's `AsyncValue` reflects the new bookmark while the mutation reports success to the button.
- Why use tsx.get inside Mutation.run instead of ref.read?`tsx.get` subscribes to the provider for the duration of the mutation, so an autoDispose provider it reads - a repository or a notifier - stays alive until the operation completes. A bare read of an unlistened autoDispose provider could be disposed while the save is still awaiting.
- When would you not use mutations, even in Riverpod 3.4?When the operation's result belongs in domain state - the notifier should own it - or when the team cannot absorb breaking changes, since the API is experimental and may change in a minor release. Simple one-off actions with no progress UI also gain nothing from a mutation.
saying these in an interview costs you the question
- Mutations are a stable part of Riverpod's main import
- Mutation.run swallows errors, so no try/catch is needed
- One Mutation instance tracks every article's bookmark separately without a key
- Mutations replace AsyncNotifier methods for all side effects
- MutationSuccess is the state before run is called