← Back to blog
Mastering Riverpod: Provider Types · Part 5 of 8
August 12, 20267 min read

AsyncNotifierProvider — Async State with Full Control in Riverpod

RiverpodFlutterDart

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> (here T = List<Todo>); build() returns FutureOr<T> and can await.
  • The exposed state is an AsyncValue<List<Todo>> — so ref.watch(todoListProvider) gives loading/data/error, exactly like FutureProvider. You render it with .when / switch.
  • AsyncNotifierProvider<Controller, T>(Controller.new) — same two-type-arg shape as NotifierProvider.
  • As in any notifier, ref is 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 returns AsyncData on success or AsyncError on failure, catching the error for you. No try/catch needed — guard turns the result into the right AsyncValue. (Compare to manual async error handlingguard is 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 FutureProvider for read-only async data. The moment you need a method that changes that data (and shows its own loading/error), promote it to an AsyncNotifier. The build() 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 AsyncValueAsyncData(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 async build() (initial fetch) and exposes state as an AsyncValue<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 setting AsyncData immediately 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.