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 aStream
) - 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 likeAsyncSnapshot
) is just a value, not a listenable object. And it's the call toref.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:
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:
- AsyncValueWidget: a reusable Flutter widget to work with AsyncValue (using Riverpod)
- How to handle loading and error states with StateNotifier & AsyncValue in Flutter
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: