Flutter Riverpod Tip: Use AsyncValue rather than FutureBuilder or StreamBuilder

If you've been using Flutter for some time, you'll have come across FutureBuilder and StreamBuilder - two handy widgets for dealing with asynchronous data in your UI.

Both widgets force you to work with AsyncSnapshot, a class that the Flutter documentation defines as:

An immutable representation of the most recent interaction with an asynchronous computation.

Uh. What was that?

Let me try again in my own words:

AsyncSnapshot represents an asynchronous value that can contain either some data, a loading state, or an error state.

While this makes more sense, working with AsyncSnapshot is a bit of a pain in practice.

So in this article we'll learn about AsyncValue, which is a much better alternative to AsyncSnapshot.

AsyncValue is included with Riverpod, an extensive state management library for Flutter. You can learn more about it on the official website or by reading my Essential Guide to Riverpod.

What's wrong with FutureBuilder and StreamBuilder?

The FutureBuilder and StreamBuilder widgets have a very similar API and take two arguments:

  • an input value (either a Future or a Stream)
  • a builder function that we can use to "map" an asynchronous snapshot to a widget
FutureBuilder( future: someFuture, builder: (context, snapshot) { // check snapshot for loading, data, and errors // and return a widget }, ); StreamBuilder( stream: someStream, builder: (context, snapshot) { // check snapshot for loading, data, and errors // and return a widget }, );

However, as soon as you start implementing the builder code, you quickly discover that working with that pesky snapshot is not much fun. 😭

A "simple" StreamBuilder example

Suppose you have a simple Item model class, along with a function that returns a Stream<Item>:

class Item { const Item({required this.title, required this.description}); final String title; final String description; } // this could represent data that comes from a realtime database Stream<Item> getItem() { ... }

If you want to use StreamBuilder to rebuild your UI whenever a new stream value is emitted, here's the kind of code you have to write:

StreamBuilder<Item>( stream: getItem(), builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { // handle loading return const Center(child: CircularProgressIndicator()); } else if (snapshot.hasData) { // handle data final item = snapshot.data!; return ListTile( title: Text(item.title), subtitle: Text(item.description), ); } else if (snapshot.hasError) { // handle error (note: snapshot.error has type [Object?]) final error = snapshot.error!; return Text(error.toString()); } else { // uh, oh, what goes here? return Text('Some error occurred - welp!'); } }, );

This is not great because you need to check at least three different properties (connectionState, data, and error) to handle all cases, but the compiler won't warn you if you forget to do so.

In practice, the loading, error, and data states should be mutually exclusive, but the AsyncSnapshot API doesn't have a way to express that.

In fact, if you peek at its definition in the Flutter SDK, you'll find that it has these properties:

class AsyncSnapshot<T> { final ConnectionState connectionState; final T? data; final Object? error; final StackTrace? stackTrace; bool get hasData => data != null; bool get hasError => error != null; }

The main problem here is that the connectionState, data, error, and stackTrace variables are all independent of each other. But this is not a good representation for states that should be mutually exclusive.

And this is what leads to all the if-else statements inside our builder.

The real problem: Dart doesn't have sealed unions

What we're trying to do is not easy because the Dart language doesn't have proper support for sealed unions (yet).

But we can do much better than this, even with the current language limitations. And the AsyncValue class from the Riverpod package shows us how it's done. 👇

AsyncValue as an alternative to AsyncSnapshot

The AsyncValue class uses factory constructors to define three mutually exclusive states: data, loading, and error.

abstract class AsyncValue<T> { const factory AsyncValue.data(T value) = AsyncData<T>; const factory AsyncValue.loading() = AsyncLoading<T>; const factory AsyncValue.error(Object error, {StackTrace? stackTrace}) = AsyncError<T>; }

And it gives us an AsyncValueX extension with various pattern matching methods that we can use to map those states to the UI.

This means that we can do this:

import 'package:flutter_riverpod/flutter_riverpod.dart'; final itemStreamProvider = StreamProvider<Item>((ref) { return getItem(); // returns a Stream<Item> }); class ItemWidget extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { // get the AsyncValue<Item> by watching the StreamProvider final itemValue = ref.watch(itemStreamProvider); // map all its states to widgets and return the result return itemValue.when( data: (item) => ListTile( title: Text(item.title), subtitle: Text(item.description), ), loading: () => const Center(child: CircularProgressIndicator()), error: (e, st) => Center(child: Text(e.toString())), ); } );

One thing that sometimes confuses people is that AsyncValue (much like AsyncSnapshot) is just a value, not a listenable object. And it's the call to ref.watch(provider) that causes the widget to rebuild.

This is much more concise and works great with autocomplete too. 🚀

And if we compare the line count with the StreamBuilder example, the difference is quite stark:

A line-by-line comparison between StreamBuilder and AsyncValue
A line-by-line comparison between StreamBuilder and AsyncValue

AsyncValue may be the best invention since sliced bread. 🚀

And if you want to use it, you'll need to follow some setup steps.

Let's take a look. 👇

1. Riverpod installation & setup

Of course, you can only take advantage of AsyncValue if you use Riverpod in your Flutter app.

So make sure to add it to your project:

flutter pub add flutter_riverpod

This will include it as a dependency in your pubspec.yaml file:

dependencies: flutter_riverpod: ^1.0.3

And you must also remember to wrap your entire app with a ProviderScope:

void main() { runApp(const ProviderScope(child: MyApp())); }

2. Create a StreamProvider or FutureProvider as needed

Since the built-in FutureBuilder and StreamBuilder widgets take a Future or Stream directly as arguments, no extra steps are needed to use them (provided that you have access to the Future or Stream itself).

But with Riverpod, each Future or Stream that you want to use in the UI should get its own provider:

final itemStreamProvider = StreamProvider<Item>((ref) { // return a Stream<Item> }); final itemFutureProvider = FutureProvider<Item>((ref) { // return a Future<Item> });

3. Create a ConsumerWidget and watch your provider

This also means that you have to convert your widget class to a ConsumerWidget and get an extra WidgetRef argument in the build() method:

// base class is now [ConsumerWidget] class SomeWidget extends ConsumerWidget { // build method now gets a ref argument @override Widget build(BuildContext context, WidgetRef ref) { final streamAsyncValue = ref.watch(itemStreamProvider); // final futureAsyncValue = ref.watch(itemFutureProvider); return streamAsyncValue.when(...); } }

This way, you can call ref.watch(provider), and the widget will rebuild whenever a new AsyncValue is emitted.

Riverpod is an extensive state management library with a lot of features. For a complete overview, see my Essential Guide to Riverpod.

Conclusion

As we have seen, the built-in FutureBuilder and StreamBuilder widgets give us an AsyncSnapshot that we can use to work with asynchronous data in the UI.

But this is hard to use in practice, and the AsyncValue class offers a much more ergonomic API.

And if you have a lot of asynchronous data in your app, you can create some helper classes on top of AsyncValue to further increase your mileage.

And these articles cover all the details:

Flutter Foundations Course Now Available

I launched a brand new course that covers state management with Riverpod in great depth, along with other important topics like app architecture, routing, testing, and much more:

Want More?

Invest in yourself with my high-quality Flutter courses.

Flutter & Firebase Masterclass

Flutter & Firebase Masterclass

Learn about Firebase Auth, Cloud Firestore, Cloud Functions, Stripe payments, and much more by building a full-stack eCommerce app with Flutter & Firebase.

The Complete Dart Developer Guide

The Complete Dart Developer Guide

Learn Dart Programming in depth. Includes: basic to advanced topics, exercises, and projects. Fully updated to Dart 2.15.

Flutter Animations Masterclass

Flutter Animations Masterclass

Master Flutter animations and build a completely custom habit tracking application.