RxDart by example: combineLatest and data modeling with Firestore

Source code on GitHub

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.

Movies collection in Firestore
Movies collection in Firestore

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>>.

Data flow diagram (single stream)
Data flow diagram (single stream)

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:

Single list of movies
Single list of movies

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.

User favourites collection in Firestore
User favourites collection in Firestore

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:

User favourites collection in Firestore
User favourites collection in Firestore

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:

Completed movie app
Completed movie app

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 StreamBuilders: one inside MoviesList, and one inside MovieListItem.

And in general (but not always!), it is a good idea to avoid nested StreamBuilders 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 the isFavourite flag and a String movieId, so that we can reference the movie inside the movies 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 the orElse closure to return null if we don't find a match.
  • then we return a MovieUserFavourite object, setting isFavourite to false 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 StreamBuilders.

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 and zip, so make sure to check them out. But combineLatest 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 StreamSubscriptions 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!

Want More?

Invest in yourself with my high-quality Flutter courses.

Flutter In Production

Flutter In Production

Learn about flavors, environments, error monitoring, analytics, release management, CI/CD, and finally ship your Flutter apps to the stores. 🚀

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. Last updated to Dart 2.15.

Flutter Animations Masterclass

Flutter Animations Masterclass

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