AsyncNotifier and FutureProvider Data Pipelines
Model loading, error, and data states cleanly using AsyncNotifier and AsyncValue patterns.
AsyncNotifier and FutureProvider Data Pipelines is a free Flutter Mobile Development lesson on CoddyKit — lesson 3 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the Flutter Mobile Development learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.
The Three States of Async Data
Every screen that loads data from the network must answer three questions: Is it loading? Did it fail? What is the data?
Riverpod 2.0 models these three states with a single sealed type called AsyncValue<T>. Instead of juggling separate isLoading, error, and data fields by hand, you receive one value that is always in exactly one of three shapes:
AsyncLoading<T>— the future is still runningAsyncError<T>— it threw, carrying the error and stack traceAsyncData<T>— it completed successfully, carrying the value
Both FutureProvider and AsyncNotifier expose their state as an AsyncValue, which is why the UI code for either looks identical.
FutureProvider: the simplest pipeline
FutureProvider is the lightest way to run an async computation and expose its result. You give it an async callback; Riverpod runs it, caches the result, and rebuilds dependents as the state moves from loading to data or error.
Use it when the data is read-only and has no side-effect methods — for example, fetching a user profile once and displaying it.
final userProfileProvider = FutureProvider<UserProfile>((ref) async {
final repo = ref.watch(profileRepositoryProvider);
return repo.fetchProfile();
});
// In a ConsumerWidget
Widget build(BuildContext context, WidgetRef ref) {
final profile = ref.watch(userProfileProvider);
return profile.when(
loading: () => const CircularProgressIndicator(),
error: (err, st) => Text('Failed: $err'),
data: (p) => Text('Hello, ${p.name}'),
);
}Pattern-matching with .when
The .when method forces you to handle all three cases, so you can never forget a loading spinner or an error message. It is the idiomatic way to render an AsyncValue in the UI.
Two extra flags are worth knowing:
skipLoadingOnRefresh— keep showing old data while a refresh runs in the background instead of flashing a spinner.skipLoadingOnReload— same idea when a dependency rebuilds the provider.
For richer control you can also pattern-match directly on the sealed subclasses using Dart 3 switch expressions.
final state = ref.watch(userProfileProvider);
final widget = switch (state) {
AsyncData(:final value) => Text('Hello, ${value.name}'),
AsyncError(:final error) => Text('Error: $error'),
_ => const CircularProgressIndicator(),
};When you need methods: AsyncNotifier
FutureProvider can only read. The moment you need to mutate state — refresh, add, toggle, delete — you reach for AsyncNotifier.
An AsyncNotifier<T> has a build() method that returns a Future<T> for the initial load, plus your own public methods that update state. The class holds business logic; the widget just calls its methods.
Key rule: build() is the initial pipeline. Riverpod automatically wraps its result so state starts as AsyncLoading, then becomes AsyncData or AsyncError — you never set those manually for the initial load.
class TodosNotifier extends AsyncNotifier<List<Todo>> {
@override
Future<List<Todo>> build() async {
final repo = ref.watch(todoRepositoryProvider);
return repo.fetchTodos();
}
}
final todosProvider =
AsyncNotifierProvider<TodosNotifier, List<Todo>>(TodosNotifier.new);Mutating with AsyncValue.guard
Inside a mutation method you must set state = const AsyncLoading() before the async work, then capture either data or error afterward. Writing that try/catch by hand is repetitive and easy to get wrong.
AsyncValue.guard does it for you: it runs an async callback and returns AsyncData on success or AsyncError (with the stack trace) on throw. This keeps the loading and error states consistent automatically.
Future<void> addTodo(String label) async {
final repo = ref.read(todoRepositoryProvider);
// Show a spinner while the write happens.
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
await repo.create(label);
return repo.fetchTodos(); // fresh list becomes the new state
});
}Optimistic updates with the current value
Flashing a full-screen spinner on every tap feels janky. A better mobile UX keeps the existing list visible and only swaps in the new data when it arrives.
The trick is to read state.value (the last known data) and pass it to AsyncLoading via copyWithPrevious, or simply build the next list optimistically. Riverpod's requireValue gives you the data or throws if there is none yet.
Future<void> toggle(String id) async {
final previous = state.requireValue;
// Optimistic: update UI immediately.
final optimistic = [
for (final t in previous)
if (t.id == id) t.copyWith(done: !t.done) else t,
];
state = AsyncData(optimistic);
// Reconcile with the server; revert on failure.
state = await AsyncValue.guard(() async {
await ref.read(todoRepositoryProvider).toggle(id);
return ref.read(todoRepositoryProvider).fetchTodos();
});
}Refreshing and invalidating
To re-run a provider's pipeline from the UI — say on pull-to-refresh — you do not call a method on the notifier. You ask Riverpod to rebuild the provider:
ref.invalidate(provider)— discards the cached value;build()runs again next read.ref.refresh(provider)— same, but returns the new value/future so you canawaitit.
Because build() re-runs, the state automatically cycles back through AsyncLoading then AsyncData. Pair this with skipLoadingOnRefresh: false if you want a spinner during refresh.
RefreshIndicator(
onRefresh: () => ref.refresh(todosProvider.future),
child: ref.watch(todosProvider).when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, st) => ErrorView(error: e),
data: (todos) => TodoListView(todos: todos),
),
);Chaining providers into a pipeline
Real data flows are pipelines: an auth token feeds a user id, which feeds a list of orders. With Riverpod you express this by watch-ing one async provider inside another.
When you await ref.watch(other.future), the outer provider waits for the inner one's data, and if the inner provider re-emits, the outer pipeline automatically re-runs. This composes AsyncValues without manual chaining.
final ordersProvider = FutureProvider<List<Order>>((ref) async {
// Wait for the upstream user before fetching their orders.
final user = await ref.watch(userProfileProvider.future);
final repo = ref.watch(orderRepositoryProvider);
return repo.fetchOrdersFor(user.id);
});Parameterized pipelines with .family
Often the pipeline depends on an argument — a product id, a search query. The .family modifier creates a distinct provider instance per argument, each with its own cached AsyncValue.
Combine it with autoDispose so instances are freed when no widget watches them — important on mobile to avoid leaking memory across navigation.
final productProvider = FutureProvider.autoDispose
.family<Product, String>((ref, id) async {
final repo = ref.watch(catalogRepositoryProvider);
return repo.fetchProduct(id);
});
// Usage: each id gets its own loading/error/data cache.
final product = ref.watch(productProvider('sku-42'));Modeling AsyncValue without Flutter
To really understand the three-state model, it helps to see it as plain Dart. Below is a tiny sealed-class reimplementation that mirrors how AsyncValue behaves — loading, error, and data — and a when-style fold over it.
This is the exact mental model Riverpod uses under the hood; the real type just adds caching, previous-value tracking, and stack traces.
sealed class Async<T> {
const Async();
}
class Loading<T> extends Async<T> { const Loading(); }
class Failure<T> extends Async<T> {
final Object error;
const Failure(this.error);
}
class Data<T> extends Async<T> {
final T value;
const Data(this.value);
}
String render(Async<int> s) => switch (s) {
Loading() => 'loading...',
Failure(:final error) => 'error: $error',
Data(:final value) => 'data: $value',
};
void main() {
print(render(const Loading()));
print(render(const Failure('timeout')));
print(render(const Data(42)));
}Avoiding common pipeline pitfalls
A few rules keep your AsyncNotifier pipelines clean:
- Never use
ref.readfor dependencies insidebuild()— useref.watchso the pipeline rebuilds when they change. Useref.readonly inside one-shot mutation methods. - Do not catch and swallow errors in
build()— let them throw so the state becomesAsyncErrorand the UI can show it. - Return the future, not void, from refresh handlers so pull-to-refresh awaits completion.
- Keep
build()idempotent — it can run many times; avoid side effects there.
Quick Check: Read vs Mutate
You are building a todo screen. The list loads from an API and users can add new todos. Which provider choice is correct?
Recap
You now have the full async data pipeline toolkit in Riverpod 2.0:
AsyncValue<T>models loading / error / data as one sealed value, rendered with.whenor aswitch.FutureProvideris for read-only async data with no methods.AsyncNotifieradds mutation methods;build()is the initial pipeline and must useref.watchfor dependencies.AsyncValue.guardwraps mutations so loading and error states stay consistent;requireValueenables optimistic updates.ref.invalidate/ref.refreshre-run the pipeline;.familyandautoDisposehandle parameterized, memory-safe pipelines.
Reach for FutureProvider when you only read, and AsyncNotifier the moment you need to write.
Frequently asked questions
Is the “AsyncNotifier and FutureProvider Data Pipelines” lesson free?
Yes — the full text of “AsyncNotifier and FutureProvider Data Pipelines” is free to read here on the web, and the Flutter Mobile Development course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the Flutter Mobile Development course, upgrade to CoddyKit PRO.
What will I learn in “AsyncNotifier and FutureProvider Data Pipelines”?
Model loading, error, and data states cleanly using AsyncNotifier and AsyncValue patterns. You practise Flutter Mobile Development with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.
Do I need any experience to start Flutter Mobile Development?
No prior experience is required. Flutter Mobile Development on CoddyKit is structured for beginners through advanced learners; this is — lesson 3 of 4, so you can start here or from the beginning and move at your own pace.
How long does the “AsyncNotifier and FutureProvider Data Pipelines” lesson take?
Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.
Can I write and run code in this Flutter Mobile Development lesson?
Yes. Every Flutter Mobile Development lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.
All lessons in this course
- From Provider to Riverpod: Migrating Legacy State
- Code Generation with riverpod_generator and @riverpod
- AsyncNotifier and FutureProvider Data Pipelines
- Provider Scoping, Overrides, and ProviderObserver