AsyncNotifierProvider
This is Part 5 of Mastering Riverpod: Provider Types. NotifierProvider gave us mutable synchronous state. FutureProvider gave us read-only async data. AsyncNotifierProvider is the powerful combination of both: state that is fetched asynchronously and can be mutated with methods — each mutation managing its own loading/error. It's the backbone of real CRUD screens (a todo list you load from the server, then add/edit/delete).
If NotifierProvider is the async-aware big sibling of Provider, then AsyncNotifier is the mutable big sibling of FutureProvider.
The shape: async build() + mutation methods
An AsyncNotifier looks like a Notifier, with two differences: build() is async (returns a Future/FutureOr), and its state is an AsyncValue.
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class TodoListController extends AsyncNotifier<List<Todo>> {
@override
FutureOr<List<Todo>> build() async {
// Async initial state — fetch from the repository.
return ref.watch(todoRepositoryProvider).fetchAll();
}
// ... mutation methods below
}
final todoListProvider =
AsyncNotifierProvider<TodoListController, List<Todo>>(TodoListController.new);
Key points:
- Extend
AsyncNotifier<T>(hereT = List<Todo>);build()returnsFutureOr<T>and canawait. - The exposed state is an
AsyncValue<List<Todo>>— soref.watch(todoListProvider)gives loading/data/error, exactly likeFutureProvider. You render it with.when/switch. AsyncNotifierProvider<Controller, T>(Controller.new)— same two-type-arg shape asNotifierProvider.- As in any notifier,
refis a class property.
So reading it in the UI is identical to a FutureProvider:
ref.watch(todoListProvider).when(
loading: () => const CircularProgressIndicator(),
error: (e, st) => Text('Error: $e'),
data: (todos) => TodoListView(todos),
);
The new power is the methods that mutate this async state.
Mutating async state: state = with AsyncValue
Because state is an AsyncValue, mutation methods set state to AsyncValue instances. The canonical pattern for an action that performs async work:
Future<void> addTodo(String title) async {
// 1. Show loading while the action runs.
state = const AsyncLoading();
// 2. Run the work, capturing success OR error into an AsyncValue.
state = await AsyncValue.guard(() async {
await ref.read(todoRepositoryProvider).add(title); // do the write
return ref.read(todoRepositoryProvider).fetchAll(); // return new state
});
}
Two new tools:
state = const AsyncLoading()— flips the UI to a loading state during the action.AsyncValue.guard(fn)— runs an async function and returnsAsyncDataon success orAsyncErroron failure, catching the error for you. Notry/catchneeded —guardturns the result into the rightAsyncValue. (Compare to manual async error handling —guardis the Riverpod shortcut.)
This is the single most important AsyncNotifier idiom: state = AsyncLoading() → state = await AsyncValue.guard(...). It gives every mutation correct loading and error handling with almost no code.
Keeping old data visible during a mutation
Flipping to AsyncLoading() blanks the screen, which is often jarring (the list disappears while adding one item). AsyncValue lets you preserve the previous data during loading, so the UI shows the old list with a subtle spinner instead of going blank:
Future<void> addTodo(String title) async {
// Keep showing current data while loading (copyWithPrevious under the hood).
state = const AsyncLoading<List<Todo>>().copyWithPrevious(state);
state = await AsyncValue.guard(() async {
await ref.read(todoRepositoryProvider).add(title);
return ref.read(todoRepositoryProvider).fetchAll();
});
}
When you render with .when(skipLoadingOnRefresh: true, ...) (Part 2), the list stays on screen during the refresh. This "show stale while revalidating" pattern is what makes Riverpod CRUD feel smooth.
Optimistic updates (advanced, but common)
For snappy UIs, you can update the state immediately (optimistically), then reconcile with the server — rolling back on failure. Because you control state directly, this is straightforward:
Future<void> toggle(String id) async {
final previous = state.valueOrNull ?? [];
// Optimistically update the UI right away:
state = AsyncData([
for (final t in previous)
if (t.id == id) t.copyWith(done: !t.done) else t,
]);
// Then persist; on error, roll back to the previous state.
try {
await ref.read(todoRepositoryProvider).toggle(id);
} catch (e, st) {
state = AsyncData(previous); // rollback
// optionally surface the error
}
}
The user sees the toggle instantly; if the network fails, it reverts. This level of control — async loading and fine-grained mutation — is exactly why AsyncNotifier exists.
AsyncNotifier vs FutureProvider
They look similar (both async, both AsyncValue), so when do you upgrade?
| | FutureProvider | AsyncNotifierProvider |
| --- | --- | --- |
| State | async, read-only | async, mutable |
| Has methods? | no | yes (add/edit/delete/refresh) |
| Use for | fetch-and-display | fetch and modify (CRUD) |
The rule: start with
FutureProviderfor read-only async data. The moment you need a method that changes that data (and shows its own loading/error), promote it to anAsyncNotifier. Thebuild()body is often identical — you're just adding mutation methods around it.
And vs Notifier (Part 4): use AsyncNotifier when the initial state requires await; use Notifier when it's synchronous.
Refreshing from the UI
Because the controller's build() does the fetch, re-running it re-fetches. The UI can trigger that with ref.invalidate/ref.refresh (Series 3):
onRefresh: () => ref.invalidate(todoListProvider), // re-runs build() → re-fetch
A common pattern is to expose a refresh() method on the controller that invalidates itself, keeping the UI clean.
Practice Challenges
Challenge 1 — Async build. Write an AsyncNotifier<List<Post>> whose build() fetches posts.
Show solution
class PostsController extends AsyncNotifier<List<Post>> {
@override
FutureOr<List<Post>> build() => ref.watch(postRepoProvider).fetchAll();
}
final postsProvider = AsyncNotifierProvider<PostsController, List<Post>>(PostsController.new);
Challenge 2 — Add with guard. Add an add(Post) method using the loading + guard idiom.
Show solution
Future<void> add(Post p) async {
state = const AsyncLoading();
state = await AsyncValue.guard(() async {
await ref.read(postRepoProvider).add(p);
return ref.read(postRepoProvider).fetchAll();
});
}
Challenge 3 — Render it. Show postsProvider with loading/error/data.
Show solution
ref.watch(postsProvider).when(
loading: () => const CircularProgressIndicator(),
error: (e, _) => Text('$e'),
data: (posts) => PostList(posts),
);
Identical to a FutureProvider — state is an AsyncValue.
Challenge 4 — Keep data visible. Modify add so the list doesn't blank out while adding.
Show solution
state = const AsyncLoading<List<Post>>().copyWithPrevious(state);
state = await AsyncValue.guard(/* ... */);
copyWithPrevious(state) keeps the previous data during loading (pair with skipLoadingOnRefresh).
Challenge 5 — Which provider? You fetch a profile (read-only) on one screen, but another screen edits it. Same provider type?
Show solution
Use an AsyncNotifierProvider since the profile is edited somewhere — it needs mutation methods (updateProfile). A read-only FutureProvider can't expose edits; once any consumer mutates the data, the source of truth should be an AsyncNotifier.
Questions to test yourself
Q1 (basic). How does AsyncNotifier differ from Notifier?
Show answer
AsyncNotifier's build() is async (returns FutureOr<T> and can await), and its state is exposed as an AsyncValue<T> (loading/data/error). Notifier's build() is synchronous and its state is a plain T.
Q2 (basic). What type does ref.watch(asyncNotifierProvider) return?
Show answer
An AsyncValue<T> — handled with .when/switch exactly like a FutureProvider.
Q3 (intermediate). What's the canonical idiom for an async mutation method?
Show answer
Set state = const AsyncLoading(); to show loading, then state = await AsyncValue.guard(() async { /* do work, return new state */ });. AsyncValue.guard runs the function and yields AsyncData on success or AsyncError on failure — automatic error handling.
Q4 (intermediate). What does AsyncValue.guard do for you?
Show answer
It runs an async function and converts the outcome into an AsyncValue — AsyncData(result) on success, AsyncError(error, stack) on exception — so you don't write try/catch. Assigning its result to state gives the mutation correct error handling.
Q5 (intermediate). How do you keep the previous data visible while a mutation is loading?
Show answer
Use state = const AsyncLoading<T>().copyWithPrevious(state); so the loading state carries the previous value; render with .when(skipLoadingOnRefresh: true, ...). The UI shows the old data with a subtle loading indication instead of blanking out ("stale-while-revalidate").
Q6 (advanced). When should you promote a FutureProvider to an AsyncNotifierProvider?
Show answer
When you need to mutate the async data from the UI — add/edit/delete/refresh actions that each manage their own loading/error. The build() (fetch) is often unchanged; you wrap it in an AsyncNotifier and add mutation methods. FutureProvider stays for purely read-only async data.
Wrapping up
AsyncNotifierProvider is async state you can change:
- An
AsyncNotifier<T>has an asyncbuild()(initial fetch) and exposes state as anAsyncValue<T>— rendered with.when/switch. - Mutate with the idiom
state = AsyncLoading()→state = await AsyncValue.guard(...)for automatic loading + error handling. - Keep data visible during mutations with
copyWithPrevious; do optimistic updates by settingAsyncDataimmediately and rolling back on failure. - It's
FutureProvider+ mutation methods — promote to it when read-only async data needs to be modified.
We've now covered five of the six grid types. The last is the rarest but completes the picture: combining a stream with mutations. Part 6 is StreamNotifierProvider — combining streams with mutations.