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

FutureProvider — Handling Async Data the Clean Way in Riverpod

RiverpodFlutterDart

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; use ref.watch(other) to get its current AsyncValue without awaiting. The .future form 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 synchronousProvider (Part 1) | | you want automatic loading/error + caching | the value is a continuous StreamStreamProvider (Part 3) |

The key limitation: FutureProvider is read-only — it fetches and exposes, but offers no way to change the data from the UI. The moment you need methods like addTodo() or updateProfile() on top of async state, you've outgrown FutureProvider and want an AsyncNotifierProvider (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 async function returning a Future<T>; the provider type is FutureProvider<T> (the resolved type).
  • ref.watch returns an AsyncValue<T> — the sealed loading/data/error type.
  • Render it exhaustively with .when (or a Dart 3 switch) — replacing FutureBuilder boilerplate.
  • It caches the result (refresh via ref.refresh/invalidate) and can chain async dependencies via ref.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.