This tutorial is about RxDart, a very useful package for working with observables/streams of data that change over time.
RxDart can make our life easier, especially when we use Firestore as a remote database.
So not only we will talk about RxDart, but we will also learn about data modeling in Firestore.
RxDart has a very broad API. If I wanted to cover it extensively, I could create an entire course about it.
Instead, here I will focus on one specific use case where RxDart really shines.
Prerequisites
To follow this tutorial, you'll need to be already familiar with streams, StreamBuilder, and Firestore collections and documents.
CombineLatest
combineLatest
is my go-to API method when I need to combine multiple streams into one. This is a very common use case with Firestore, where we read data in realtime via streams.
And to connect the Firestore realtime data to the UI, we can use StreamBuilder
and rebuild our widgets when there is a new value.
So this tutorial is divided in two parts:
- we will start with an example app that shows a list of items coming from a single collection on Firestore.
- then, we will introduce a new requirement and see how to combine data from multiple collections.
We will explore two ways of doing this, and discuss the pros and cons at the end.
This tutorial will include the most relevant code snippets, but you can find the full source code on GitHub, and see how everything fits together.
Example app: Movie favourites
Let's suppose that we want to show a list of movies.
This is represented as a top-level collection in Firestore. Each document is a movie, and it contains the title, a description, and possibly some other metadata.
To show this data in a Flutter app, can parse all these documents into a list of Movie
objects, and make them accessible with a Stream<List<Movie>>
.
To show the data, we can use a StreamBuilder
to get the stream and rebuild a ListView
every time the data changes:
class MoviesList extends StatelessWidget {
@override
Widget build(BuildContext context) {
final database = Provider.of<FirestoreDatabase>(context, listen: false);
return StreamBuilder<List<Movie>>(
stream: database.moviesStream(),
builder: (_, snapshot) {
// NOTE: snapshot.connectionState and null checks omitted for simplicity
final movies = snapshot.data;
return ListView.builder(
itemCount: movies.length,
itemBuilder: (_, index) => MovieListItem(
movie: movies[index],
),
);
},
);
}
}
And we can pass each Movie
object to a MovieListItem
widget that draws a ListTile
on screen:
class MovieListItem extends StatelessWidget {
const MovieListItem({Key key, this.movie}) : super(key: key);
final Movie movie;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: ListTile(
title: Text(movie.title),
subtitle: Text(movie.description),
),
);
}
}
The end result looks like this:
This setup is quite simple because there is a 1 to 1 map between each Firestore document, and each MovieListItem
on screen.
Adding user favourites
Let's introduce a new requirement: we want users to be able to save their favourite movies.
Different users will have different favourites.
So we can't store favourite
metadata for each user inside the global movies
collection.
Instead, we can add a users
collection to identify all users in our app, using FirebaseUser.uid
as the document ID.
For each user, we can add a subcollection called userFavourites
. Each document inside it contains a isFavourite
boolean, and is identified by the movieId
that we can use to reference a movie in the movies
collection.
Showing the UI
To wire things up in the UI, we can update the build
method in our MovieListItem
widget class:
@override
Widget build(BuildContext context) {
// temporary variable to be set when reading the data from Firestore
final isFavourite = false;
return Padding(
padding: const EdgeInsets.all(8.0),
child: ListTile(
title: Text(movie.title),
subtitle: Text(movie.description),
trailing: IconButton(
icon: Icon(
Icons.favorite,
// switch icon color based on isFavourite
color: isFavourite ? Colors.red : Colors.grey[300],
),
onPressed: () => _toggleFavourite(context, isFavourite),
)),
);
}
Above we added a trailing
widget with an IconButton
, showing a heart with a red color if the movie is a favourite, or grey color if it's not.
We can press this button to favourite a movie. When this happens, we call the _toggleFavourite
method:
Future<void> _toggleFavourite(BuildContext context, bool isFavourite) async {
try {
final database = Provider.of<FirestoreDatabase>(context, listen: false);
await database.setUserFavourite(UserFavourite(
isFavourite: !isFavourite,
movieId: movie.id,
));
} catch (e) {
// TODO: handle exceptions
print(e);
}
}
This code uses the movie ID to write a isFavourite
boolean value to users/$uid/userFavourites/$movieId
in Firestore. We do this with an intermediate UserFavourite
model class that handles serialization:
class UserFavourite {
final String movieId;
final bool isFavourite;
Map<String, dynamic> toMap() {
return {
'isFavourite': isFavourite,
};
}
}
But writing this data is only half of the solution. We also need to be able to read the isFavourite
data back.
Remember: when we show the list of movies, we need to know which ones are favourite for the current user.
This is a problem, because the movies data and the favourites data live in different Firestore collections:
So how can we combine this data and show our UI?
Solution 1: adding a StreamBuilder inside MovieListItem
We can add a new StreamBuilder
inside the MovieListItem
, so that we can read the isFavourite
flag for each specific movie.
The resulting build
method looks like this:
// MovieListItem widget class
@override
Widget build(BuildContext context) {
final database = Provider.of<FirestoreDatabase>(context, listen: false);
return StreamBuilder<UserFavourite>(
// reads from `users/$uid/userFavourites/$movieId`
stream: database.userFavouriteStream(movie.id),
builder: (context, snapshot) {
// read isFavourite from snapshot
final userFavourite = snapshot.data;
final isFavourite = userFavourite?.isFavourite ?? false;
return Padding(
padding: const EdgeInsets.all(8.0),
child: ListTile(
title: Text(movie.title),
subtitle: Text(movie.description),
trailing: IconButton(
icon: Icon(
Icons.favorite,
color: isFavourite ? Colors.red : Colors.grey[300],
),
onPressed: () => _toggleFavourite(context, isFavourite),
)),
);
});
}
With these changes, the movie list now works and we can toggle the favourite icon in the UI:
This solution works, as long as we're happy to have a StreamBuilder
inside our MovieListItem
class.
However, the resulting widget hierarchy has two nested StreamBuilder
s: one inside MoviesList
, and one inside MovieListItem
.
And in general (but not always!), it is a good idea to avoid nested StreamBuilder
s to minimize widget rebuilds.
So in the rest of this tutorial we will try a different solution. We will combine the data from two separate collections, and produce just one output stream that has all the data our UI needs.
And at the end, we will assess the pros and cons of each solution.
Solution 2: using combineLatest
First of all, we want to add the latest version of rxdart
to our pubspec.yaml
file.
Then we can write a FirestoreDatabase
class to get access to the required Firestore collections with some convenience methods (see my starter architecture tutorial for more details on this):
class FirestoreDatabase {
FirestoreDatabase({@required this.uid}) : assert(uid != null);
final String uid;
// see project on GitHub for how FirestoreService is implemented
final _service = FirestoreService.instance;
Future<void> setUserFavourite(UserFavourite userFavourite) =>
_service.setData(
path: FirestorePath.userFavourite(uid, userFavourite.movieId),
data: userFavourite.toMap(),
);
// global collection of movies
Stream<List<Movie>> moviesStream() => _service.collectionStream<Movie>(
path: FirestorePath.movies(),
builder: (data, documentId) => Movie.fromMap(data, documentId),
);
// user-specific favourites collection
Stream<List<UserFavourite>> userFavouritesStream() =>
_service.collectionStream<UserFavourite>(
path: FirestorePath.userFavourites(uid),
builder: (data, documentId) => UserFavourite.fromMap(data, documentId),
);
Stream<UserFavourite> userFavouriteStream(String movieId) =>
_service.documentStream<UserFavourite>(
path: FirestorePath.userFavourite(uid, movieId),
builder: (data, documentId) => UserFavourite.fromMap(data, documentId),
);
}
Reminder: the
UserFavourite
class contains theisFavourite
flag and aString movieId
, so that we can reference the movie inside themovies
collection.
Next, we want to find a way to combine moviesStream()
and userFavouritesStream()
, and generate a Stream<List<MovieUserFavourite>>
, where MovieUserFavourite
is defined like so:
class MovieUserFavourite {
final Movie movie;
final bool isFavourite;
}
To clarify what we're trying to do, here is a diagram of the entire data flow:
Our goal is to combine the data from these two streams using combineLatest
, and have one output stream that we can feed into our UI.
Rather than adding the new stream to FirestoreDatabase
, we can add it to a new MoviesListViewModel
class, that will be used by the MoviesList
widget:
class MoviesListViewModel {
MoviesListViewModel({@required this.database});
final FirestoreDatabase database;
/// returns the entire movies list with user-favourite information
Stream<List<MovieUserFavourite>> moviesUserFavouritesStream() {
// TODO: combine streams here
}
}
So our challenge is to add the logic for combining the two input streams, and returning a new stream.
Ready? Let's go! 💪
Step one is to call the Rx.combineLatest2
method (don't forget to import 'package:rxdart/rxdart.dart';
):
/// returns the entire movies list with user-favourite information
Stream<List<MovieUserFavourite>> moviesUserFavouritesStream() {
return Rx.combineLatest2(
database.moviesStream(), database.userFavouritesStream(),
(List<Movie> movies, List<UserFavourite> userFavourites) {
// TODO: combine logic here
});
}
This method takes three arguments:
- the first two are the input streams that we want to combine.
- the third is a closure that gives us the most recent data from the two streams.
Inside this closure we need to map over the list of movies:
return movies.map((movie) {
final userFavourite = userFavourites?.firstWhere(
(userFavourite) => userFavourite.movieId == movie.id,
orElse: () => null);
return MovieUserFavourite(
movie: movie,
isFavourite: userFavourite?.isFavourite ?? false,
);
}).toList();
Let's break this down:
- first, we map over the list of movies.
- for each movie, we find the first
UserFavourite
object with a matching movie ID, or use theorElse
closure to returnnull
if we don't find a match. - then we return a
MovieUserFavourite
object, settingisFavourite
tofalse
if we can't find a match.
Here is the full implementation:
class MoviesListViewModel {
MoviesListViewModel({@required this.database});
final FirestoreDatabase database;
/// returns the entire movies list with user-favourite information
Stream<List<MovieUserFavourite>> moviesUserFavouritesStream() {
return Rx.combineLatest2(
database.moviesStream(), database.userFavouritesStream(),
(List<Movie> movies, List<UserFavourite> userFavourites) {
return movies.map((movie) {
final userFavourite = userFavourites?.firstWhere(
(userFavourite) => userFavourite.movieId == movie.id,
orElse: () => null);
return MovieUserFavourite(
movie: movie,
isFavourite: userFavourite?.isFavourite ?? false,
);
}).toList();
});
}
}
Note about combineLatestX
Before we continue, note that we have written some custom combine logic based on our specific requirements.
If you use
combineLatest
in your own apps, you will have to write different logic, based on your use case.
But the same principle stands, because by using combineLatest
we can get one output stream from two or more input streams.
In fact, RxDart supports combineLatest2
, combineLatest3
and beyond, so that you can combine up to 9 input streams into one output stream.
Finishing the UI
The hard part is done.
And now that we have our MoviesListViewModel
, updating the UI code becomes easy.
Inside the MovieList
widget we can add a new static method and use Provider
to wire up the new view model:
static Widget create(BuildContext context) {
final database = Provider.of<FirestoreDatabase>(context, listen: false);
return Provider<MoviesListViewModel>(
create: (_) => MoviesListViewModel(database: database),
child: MoviesList(),
);
}
Then we can update the StreamBuilder
in the build
method and change the input stream like so:
final viewModel = Provider.of<MoviesListViewModel>(context, listen: false);
return StreamBuilder<List<MovieUserFavourite>>(
stream: viewModel.moviesUserFavouritesStream(),
builder: (_, snapshot) {
// NOTE: snapshot.connectionState and null checks omitted for simplicity
final movies = snapshot.data;
return ListView.builder(
itemCount: movies.length,
itemBuilder: (_, index) => MovieListItem(
movieUserFavourite: movies[index],
),
);
},
);
And the final MovieListItem
class is now simpler and no longer requires a StreamBuilder
:
class MovieListItem extends StatelessWidget {
const MovieListItem({Key key, this.movieUserFavourite}) : super(key: key);
final MovieUserFavourite movieUserFavourite;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: ListTile(
title: Text(movieUserFavourite.movie.title),
subtitle: Text(movieUserFavourite.movie.description),
trailing: IconButton(
icon: Icon(
Icons.heart,
color: movieUserFavourite.isFavourite
? Colors.red : Colors.grey[300],
),
onPressed: () => _toggleFavourite(context),
),
),
);
}
Future<void> _toggleFavourite(BuildContext context) async {
try {
final database = Provider.of<FirestoreDatabase>(context, listen: false);
await database.setUserFavourite(UserFavourite(
isFavourite: !movieUserFavourite.isFavourite,
movieId: movieUserFavourite.movie.id,
));
} catch (e) {
// TODO: handle exceptions
print(e);
}
}
}
With this, we've come full circle.
As a reminder, here's the complete system diagram for our simple app:
As we can see, there is a read-and-write loop in our data flow, which causes our UI to rebuild when the user toggles the favourite flag on a movie.
When the favourite data is written in Firestore:
- the
users/$uid/userFavourites
collection is updated - this in turn pushes a new stream value into our data flow
combineLatest2
combines this with the movies data to produce a new output- finally the
StreamBuilder
gets a new snapshots and rebuild the UI
Comparison: Nested StreamBuilders vs combineLatest
The solution using combineLatest
has some benefits, because we can easily combine data and lift some logic out of the UI layer.
And this means that our widgets become simpler.
But the devil is in the details. In fact, the combineLatest
approach leads to more widget rebuilds than when using nested StreamBuilder
s.
The reason is that in order to combine the two input streams, we need to read the entire userFavourites
collection.
Firestore top tip: collection listeners are updated when we write any of the documents inside them.
As a result, the entire list of movies is rebuilt when we favourite a single movie.
Instead, if we use a StreamBuilder
for each UserFavourite
document, only a single MovieListItem
is rebuilt when we set the isFavourite
flag.
Bottom line: in this very specific case, combining two collections is the least efficient solution, while using nested
StreamBuilders
minimises widget rebuilds.
In any case, the purpose of this tutorial was to show you how to use combineLatest
in practice. There are cases where you can use it to get better and more performant code.
Make sure to always understand the runtime behaviour of your apps as you evaluate different solutions.
How does combineLatest work?
I haven't talked about this yet.
For this, I refer you to the RxMarbles website for an interactive illustration of how combineLatest
works. You can drag and drop the input values, and see when and how output values are produced.
RxDart supports other combination operators such as
merge
,concat
andzip
, so make sure to check them out. ButcombineLatest
is the one that I seem to need most of the time.
Alternative approaches to RxDart
To wrap up this tutorial, I want to talk about alternative stream combination approaches.
Nothing stops you from creating multiple StreamSubscription
s for your input streams and rolling out your custom combine logic. But this requires to manually keep track of stream events, and piping the output data into a StreamController
or some other reactive construct.
This leads to a lot more (error-prone) code. With RxDart you get the advantage of a completely functional approach, and many useful stream-merging operations.
If you want you can also implement a different kind of view model, that manipulates the input streams from Firestore, but does not output a stream (for example, in favour of ChangeNotifier
).
But I find that with Firestore projects, using streams all the way leads to the least amount of extra boilerplate code.
So if you just need to perform stream manipulation and don't need complex logic, combineLatest
may just be good enough for you. Use it wisely. 😉
What other problems does combineLatest solve?
combineLatest
is all about combining streams. It's a good choice if you need to combine data from different Firestore collections or documents.
In tutorial we have used it to combine two collections (N-to-N relationship). You can also use it to combine one collection and one document (1-to-N), or two documents (1-to-1).
Here are some real-world examples where you could use combineLatest
:
- e-commerce app with product list + shopping cart
- Twitter-like feed + favourite tweets
- user profile page with basic data from FirebaseAuth (
displayName
,photoUrl
) and custom data from Firestore (age, bio, and other custom fields)
We've come a long way. I'd like to wrap up by saying this:
Your imagination is the limit. You can make your ideas become real with the right tools. 😀🚀
Happy coding!