FutureProvider
This is Part 2 of Mastering Riverpod: Provider Types. Plain Provider (Part 1) holds a synchronous value. But most real data — a user profile, a product list, today's weather — lives behind a network or database call that returns a Future. For that, Riverpod gives you FutureProvider, and it handles loading and error states so cleanly you'll never write a FutureBuilder again.
The problem FutureProvider solves
Loading async data in Flutter the manual way means juggling three states yourself — loading, data, error — usually with a FutureBuilder and a pile of if (snapshot.hasData) checks. It's verbose, easy to get wrong, and doesn't cache (recall the Flutter build-method trap: creating the future in build re-fires it).
FutureProvider does all of that for you: it runs an async function, caches the result, and exposes the current state as an AsyncValue — a single object that is always in exactly one of three states.
Declaring a FutureProvider
You declare it like Provider, but the function is async and returns a Future:
final userProvider = FutureProvider<User>((ref) async {
final repo = ref.watch(userRepositoryProvider); // depend on a repo (DI from Part 1)
return repo.fetchUser(); // await happens inside
});
Notice FutureProvider<User> — the type is the resolved value (User), not Future<User>. The provider runs the async function lazily on first watch, caches the eventual result, and — thanks to Riverpod 3.0's auto-retry (Foundation Part 1) — even retries on failure with backoff.
What you get back: AsyncValue
When you ref.watch(userProvider), you don't get a User — you get an AsyncValue<User>. AsyncValue is a sealed type representing the three states of any async operation:
AsyncValue<User>
├── AsyncLoading — still fetching (no data yet)
├── AsyncData(user) — succeeded, here's the value
└── AsyncError(e, st) — failed, here's the error + stack trace
Because it's sealed (Dart sealed classes!), you can handle all three states exhaustively — the compiler ensures you don't forget one. The most ergonomic way is .when:
class UserScreen extends ConsumerWidget {
const UserScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final userAsync = ref.watch(userProvider); // AsyncValue<User>
return userAsync.when(
loading: () => const CircularProgressIndicator(),
error: (err, stack) => Text('Oops: $err'),
data: (user) => Text('Hello, ${user.name}'),
);
}
}
That's the entire loading/data/error dance in one expression — no FutureBuilder, no snapshot.connectionState, no null checks. This is the headline benefit: FutureProvider + AsyncValue.when replaces all the manual async-UI boilerplate. (We devote all of Series 3 Part 1 to AsyncValue — including the switch/pattern-matching alternative to .when.)
Pattern-matching alternative
Since AsyncValue is sealed, you can also use a Dart 3 switch expression (sealed classes & patterns):
final userAsync = ref.watch(userProvider);
return switch (userAsync) {
AsyncData(:final value) => Text(value.name),
AsyncError(:final error) => Text('Error: $error'),
_ => const CircularProgressIndicator(),
};
Both .when and switch work; .when is more concise for the common case, switch shines when you want fine-grained control. Either way, the three states are handled exhaustively.
It caches — and you can refresh
A FutureProvider runs its async function once and caches the AsyncValue. Every widget watching it shares that one result — fetch the user once, show it on five screens. To re-run the fetch (pull-to-refresh, retry button), use ref.invalidate or ref.refresh (covered fully in Series 3):
// Re-run the fetch (e.g. on pull-to-refresh):
onRefresh: () => ref.refresh(userProvider.future),
This caching is exactly what manual FutureBuilder can't give you for free, and it's why FutureProvider scales: shared async data with one source of truth.
Combining: providers can await each other
A FutureProvider can depend on other providers (sync or async). When you ref.watch another FutureProvider, you can await its .future to chain async work:
final cityProvider = FutureProvider<String>((ref) async => 'London');
final weatherProvider = FutureProvider<Weather>((ref) async {
final city = await ref.watch(cityProvider.future); // await another FutureProvider
return ref.watch(weatherRepoProvider).fetch(city);
});
ref.watch(cityProvider.future) gives you the Future<String> so you can await it. If cityProvider changes, weatherProvider re-runs automatically — async dependencies in the reactive graph.
Tip: use
ref.watch(other.future)to await an async dependency; useref.watch(other)to get its currentAsyncValuewithout awaiting. The.futureform is for chaining; the plain form is for inspecting state.
Loading/error niceties
Two AsyncValue conveniences you'll use constantly (more in Series 3):
final async = ref.watch(userProvider);
// Keep showing old data while refreshing (avoid flicker):
async.when(
skipLoadingOnRefresh: true, // don't flash a spinner during a refresh
loading: () => const Spinner(),
error: (e, _) => ErrorView(e),
data: (user) => UserView(user),
);
// Quick accessors:
final user = async.valueOrNull; // User? — value if available, else null
final isLoading = async.isLoading;
When to use FutureProvider (and when not)
| Use FutureProvider when… | Use something else when… |
| --- | --- |
| fetching data once (read-only) — a profile, a config, a list | you need to mutate the result (add/edit) → AsyncNotifierProvider (Part 5) |
| the value comes from a Future | the value is synchronous → Provider (Part 1) |
| you want automatic loading/error + caching | the value is a continuous Stream → StreamProvider (Part 3) |
The key limitation:
FutureProvideris read-only — it fetches and exposes, but offers no way to change the data from the UI. The moment you need methods likeaddTodo()orupdateProfile()on top of async state, you've outgrownFutureProviderand want anAsyncNotifierProvider(Part 5).
Practice Challenges
Challenge 1 — Declare one. Write a FutureProvider<List<Post>> that fetches posts from a postRepoProvider.
Show solution
final postsProvider = FutureProvider<List<Post>>((ref) async {
return ref.watch(postRepoProvider).fetchPosts();
});
Challenge 2 — Render it. Show postsProvider with loading/error/data using .when.
Show solution
ref.watch(postsProvider).when(
loading: () => const CircularProgressIndicator(),
error: (e, _) => Text('Error: $e'),
data: (posts) => ListView(children: [for (final p in posts) Text(p.title)]),
);
Challenge 3 — Pattern match. Render the same with a Dart 3 switch.
Show solution
switch (ref.watch(postsProvider)) {
AsyncData(:final value) => PostList(value),
AsyncError(:final error) => Text('$error'),
_ => const CircularProgressIndicator(),
};
AsyncValue is sealed, so the switch is exhaustive.
Challenge 4 — Chain async. weatherProvider needs the result of cityProvider (also a FutureProvider). Write it.
Show solution
final weatherProvider = FutureProvider<Weather>((ref) async {
final city = await ref.watch(cityProvider.future);
return ref.watch(weatherRepoProvider).fetch(city);
});
ref.watch(cityProvider.future) lets you await the dependency.
Challenge 5 — Outgrown it. You have a FutureProvider<List<Todo>> and now need an addTodo() action. What should it become?
Show solution
An AsyncNotifierProvider (Part 5) — FutureProvider is read-only; once you need mutation methods on async state, you need an AsyncNotifier.
Questions to test yourself
Q1 (basic). What does FutureProvider expose, and what type do you get from ref.watch?
Show answer
It runs an async function and exposes its eventual result, but ref.watch returns an AsyncValue<T> (loading/data/error), not the raw T.
Q2 (basic). What are the three states of an AsyncValue?
Show answer
AsyncLoading (in progress), AsyncData(value) (success), and AsyncError(error, stackTrace) (failure). It's always in exactly one.
Q3 (intermediate). How does FutureProvider + .when improve on a manual FutureBuilder?
Show answer
It caches the result (one fetch shared everywhere, not re-fired on rebuild), handles loading/data/error in one exhaustive .when (no snapshot.connectionState/null checks), supports auto-retry, and is testable/overridable. FutureBuilder re-runs the future on rebuild and makes you handle states manually.
Q4 (intermediate). Why can you use a Dart switch on an AsyncValue?
Show answer
Because AsyncValue is a sealed class with subtypes AsyncData/AsyncLoading/AsyncError (Dart Part 8). A switch over a sealed type is exhaustive — the compiler ensures all states are handled.
Q5 (intermediate). How do you await one FutureProvider inside another?
Show answer
Watch its .future: final x = await ref.watch(otherProvider.future);. The .future form gives you the underlying Future to await (the plain ref.watch(otherProvider) gives the AsyncValue without awaiting). If the dependency changes, the dependent re-runs.
Q6 (advanced). What's the key limitation of FutureProvider, and what do you switch to when you hit it?
Show answer
It's read-only — it can fetch and expose async data but provides no way to mutate it from the UI. When you need methods like add()/update() on top of async state (with loading/error handling for those actions), switch to an AsyncNotifierProvider (Part 5).
Wrapping up
FutureProvider is async data, done cleanly:
- Declare it with an
asyncfunction returning aFuture<T>; the provider type isFutureProvider<T>(the resolved type). ref.watchreturns anAsyncValue<T>— the sealed loading/data/error type.- Render it exhaustively with
.when(or a Dart 3switch) — replacingFutureBuilderboilerplate. - It caches the result (refresh via
ref.refresh/invalidate) and can chain async dependencies viaref.watch(other.future). - It's read-only — for mutable async state, use
AsyncNotifierProvider.
Some data doesn't arrive once — it keeps arriving: chat messages, live prices, location updates. For a continuous feed, you want Part 3: StreamProvider — real-time data with Riverpod.