Flutter app development tutorials by Andrea Bizzotto

RxDart by example: querying the GitHub Search API with switchMap & debounce

This short tutorial shows how to implement search with RxDart in Flutter.

We will use the GitHub Search API, but the same concepts are valid for any other search REST APIs.

Our goal is to have a good search user experience, without putting too much load on the server, or compromising bandwidth and battery life on the client.

GitHub Search Example App

So let's get started by looking at a simple working app.

This uses the SearchDelegate class to show a list of users matching the input search query:

In order to build this we need a few components:

  • a GitHubSearchAPIWrapper class to pull the data from the GitHub REST API
  • a GitHubSearchResult model class that contains the API response data
  • a GitHubSearchDelegate class that shows the search UI with a grid of results
  • a GitHubSearchService class with all the logic for wiring up the API wrapper with the UI

Here's how everything is connected:

This is a short tutorial, so we will focus only on the GitHubSearchService class.

But you can check the full source code in GitHub for all the remaining details.

GitHubSearchService

Let's start with some code that represents a starting point for this class:

class GitHubSearchService { GitHubSearchService({@required this.apiWrapper}); final GitHubSearchAPIWrapper apiWrapper; // Input stream (search terms) final _searchTerms = BehaviorSubject<String>(); void searchUser(String query) => _searchTerms.add(query); // Output stream (search results) final _results = BehaviorSubject<GitHubSearchResult>(); Stream<GitHubSearchResult> get results => _results.stream; void dispose() { _results.close(); _searchTerms.close(); } }

We have an input stream for the search terms (generated as we type on the search box), and an output stream for the result data (shown as a grid of items in the UI).

This class takes a GitHubSearchAPIWrapper as a constructor argument.

And we need to work out how to add values to the output stream when the input changes.

Mapping inputs to outputs: first attempt

As a first attempt, let's add a listener to the _searchTerms stream:

GitHubSearchService({@required this.apiWrapper}) { _searchTerms.listen((query) async { print('searching: $query'); // get new result from the api final result = await apiWrapper.searchUser(query); print('received result for: $query'); // add to the output stream _results.add(result); }); }

Every time the search query changes, this code calls the API and adds the result to the output stream.

While this approach seems logical, it doesn't work very well in practice.

Why? Because there is no guarantee that the results come back in the same order as the search terms.

This is a problem when we have an unreliable connection, and can lead to our-of-order results and a bad user experience:

// example log searching: b searching: bi searching: biz searching: bizz searching: bizz8 searching: bizz84 received result for: b received result for: bi received result for: bizz received result for: bizz8 received result for: bizz84 received result for: biz

asyncMap

Let's try using the asyncMap operator instead:

GitHubSearchService({@required this.apiWrapper}) { _results = _searchTerms.asyncMap((query) async { print('searching: $query'); return await apiWrapper.searchUser(query); }); // discard previous events } // To make this work, `_results` is now decleared as a `Stream` Stream<GitHubSearchResult> _results;

asyncMap guarantees that the output events are emitted in the same order as the inputs.

This solution solves the out-of-order problem, but it has one major drawback.

If one of the output arrives late, then all the subsequent outputs are delayed too.

Instead, we should discard any in-flight requests as soon as the search query changes.

This can be done with the switchMap operator.

switchMap

switchMap does the right thing for us, and can be used like this:

GitHubSearchService({@required this.apiWrapper}) { _results = _searchTerms.switchMap((query) async* { print('searching: $query'); yield await apiWrapper.searchUser(query); }); // discard previous events }

Note that in this case we're using a stream generator with the async* syntax.

With this setup, we can discard any in-flight requests as soon as a new search term comes in.

But there is still one problem that is particularly noticeable with the GitHub Search API.

If we submit too many queries too quickly, we get a "rate limit exceeded" error:

{ "message": "API rate limit exceeded for 82.37.171.3. (But here's the good news: Authenticated requests get a higher rate limit. Check out the documentation for more details.)", "documentation_url": "https://developer.github.com/v3/#rate-limiting" }

debounce

Debounce alleviates pressure on the server by putting all input events "on hold" for a given duration.

We can easily debounce the input stream by adding one line:

_results = _searchTerms .debounce((_) => TimerStream(true, Duration(milliseconds: 500))) .switchMap((query) async* { print('searching: $query'); yield await apiWrapper.searchUser(query); }); // discard previous events

We can control the debounce duration to our liking (500ms is a good default value).

And there we have it! An efficient search implementation that feels snappy, without overloading the server or the client.

Credits

This tutorial was heavily inspired by this talk by Brian Egan & Filip Hracek at ReactiveConf 2018:

The full source code is available for the GitHub search example - feel free to use this as reference in your own apps.

Happy coding!

Want more?

Support my work and fast-track your Flutter learning with my in-depth courses.