AsyncValueWidget: a reusable Flutter widget to work with AsyncValue (using Riverpod)

Compared to using the built-in FutureBuilder and StreamBuilder Flutter widgets, working with asynchronous data is a breeze with the Riverpod package:

// A widget that shows product data for a given product ID class ProductScreen extends ConsumerWidget { const ProductScreen({Key? key, required this.productId}) : super(key: key); final String productId; @override Widget build(BuildContext context, WidgetRef ref) { // get an async value from a StreamProvider final productAsyncValue = ref.watch(productProvider(productId)); // return different "data", "loading", "error" widgets depending on the value return productAsyncValue.when( data: (product) => ProductScreenContents(product: product), loading: (_) => const Center(child: CircularProgressIndicator()), error: (e, _, __) => Center( child: Text( e.toString(), style: Theme.of(context) .textTheme .headline6! .copyWith(color: Colors.red), ), ), ); } } // NOTE: using StreamProvider.family so we can pass a custom "id" at runtime final productProvider = StreamProvider.autoDispose.family<Product, String>((ref, id) { // dataStore is an API we can use to access our DB final dataStore = ref.watch(dataStoreProvider); // return a stream with the given product ID return dataStore.product(id); });

All this magic is possible because the when method gives us a convenient pattern matching API that we can use to map our data to the UI.

Note the code above uses a so-called family to pass the productId at runtime. For more info on this, read about the family modifier on my Flutter Riverpod Essential Guide.

Don't Repeat Yourself

As you build your apps, you're likely to have different "data" widgets for different async APIs:

productAsyncValue.when( // this changes depending on which API we're using data: (product) => ProductScreenContents(product: product), // this is always the same loading: (_) => const Center(child: CircularProgressIndicator()), // this is always the same error: (e, _, __) => Center( child: Text( e.toString(), style: Theme.of(context) .textTheme .headline6! .copyWith(color: Colors.red), ), ), )

But the loading and error UIs are often the same and it would be quite repetitive to copy-paste them every time we need a new "async" widget.

The DRY Solution: AsyncValueWidget

A better option is to define an AsyncValueWidget that takes care of the loading and error states, and let us customise the UI for the data state.

This is easy to implement:

// Generic AsyncValueWidget to work with values of type T class AsyncValueWidget<T> extends StatelessWidget { const AsyncValueWidget({Key? key, required this.value, required this.data}) : super(key: key); // input async value final AsyncValue<T> value; // output builder function final Widget Function(T) data; @override Widget build(BuildContext context) { return value.when( data: data, loading: (_) => const Center(child: CircularProgressIndicator()), error: (e, _, __) => Center( child: Text( e.toString(), style: Theme.of(context) .textTheme .headline6! .copyWith(color: Colors.red), ), ), ); } }

With this in place, we can rewrite our ProductScreen like so:

class ProductScreen extends ConsumerWidget { const ProductScreen({Key? key, required this.productId}) : super(key: key); final String productId; @override Widget build(BuildContext context, WidgetRef ref) { final productAsyncValue = ref.watch(productProvider(productId)); return AsyncValueWidget<Product>( value: productAsyncValue, data: (product) => ProductScreenContents(product: product), ); } }

Much cleaner.

What about slivers?

The AsyncValueWidget class works well for regular widgets. But sometimes you have complex view hierarchies that use slivers.

For example, here's a ProductsList widget that is meant to be used as one of the slivers inside a CustomScrollView:

class ProductsList extends ConsumerWidget { const ProductsList({Key? key}) : super(key: key); @override Widget build(BuildContext context, WidgetRef ref) { final productsValue = ref.watch(productsProvider); return productsValue.when( // data data: (products) => SliverList( delegate: SliverChildBuilderDelegate( (context, index) { final product = products[index]; return ProductCard(product: product); }, childCount: products.length, ), ), // loading UI with SliverToBoxAdapter loading: (_) => const SliverToBoxAdapter( child: Center(child: CircularProgressIndicator())), // error UI with SliverToBoxAdapter error: (e, _, __) => SliverToBoxAdapter( child: Center( child: Text( e.toString(), style: Theme.of(context) .textTheme .headline6! .copyWith(color: Colors.red), ), ), ), ); } } // use like this: CustomScrollView( slivers: [ SliverToBoxAdapter( // title child: Text('Products List', style: Theme.of(context).textTheme.headline4), ) // contents const ProductsList(), // optionally, add some other slivers ] )

In this case, both the loading and error widgets need to be wrapped inside a SliverToBoxAdapter.

But our AsyncValueWidget doesn't do that and if we try to use it inside our ProductsList we get this error:

A RenderViewport expected a child of type RenderSliver but received a child of type RenderPositionedBox.

The solution to this is to create an AsyncValueSliverWidget that does the right thing:

class AsyncValueSliverWidget<T> extends StatelessWidget { const AsyncValueSliverWidget( {Key? key, required this.value, required this.data}) : super(key: key); // input async value final AsyncValue<T> value; // output builder function final Widget Function(T) data; @override Widget build(BuildContext context) { return value.when( data: data, loading: (_) => const SliverToBoxAdapter( child: Center(child: CircularProgressIndicator()) ), error: (e, _, __) => SliverToBoxAdapter( child: Center( child: Text( e.toString(), style: Theme.of(context) .textTheme .headline6! .copyWith(color: Colors.red), ), ), ), ); } }

And then, we can use it like this:

class ProductsList extends ConsumerWidget { const ProductsList({Key? key}) : super(key: key); @override Widget build(BuildContext context, WidgetRef ref) { final productsValue = ref.watch(productsProvider); return AsyncValueSliverWidget<List<Product>>( value: productsValue, data: (products) => SliverList( delegate: SliverChildBuilderDelegate( (context, index) { final product = products[index]; return ProductCard(product: product); }, childCount: products.length, ), ), ); } }

Much better.

Conclusion

The two AsyncValueWidget and AsyncValueSliverWidget utility classes defined above help us make our code more DRY.

This saves us a few lines of code and if we decide to change the styling for our loading and error UI, we need to do that in one place only.

This may seem like a small win, but it all adds up.

Happy coding!

Want More?

Invest in yourself with my high-quality Flutter courses.

Flutter Foundations Course

Flutter Foundations Course

Learn about State Management, App Architecture, Navigation, Testing, and much more by building a Flutter eCommerce app on iOS, Android, and web.

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.