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!