BeerThink: infinite scrolling in a mobile app with Ionic, Node.js, and RethinkDB
Developers often use pagination to display large collections of data. An application can fetch content in batches as needed, presenting a fixed number of records at a time. On the frontend, paginated user interfaces typically provide something like “next” and “previous” navigation buttons so that users can move through the data set. In modern mobile apps, it is increasingly common to implement an infinite scrolling user interface on top of paginated data. As the user scrolls through a list, the application fetches and appends new records.
To demonstrate the use of pagination in RethinkDB applications, I made a simple mobile app called BeerThink. It displays a list of beers and breweries, providing a detailed summary when the user taps an item. The app uses a data dump from the Open Beer Database, which contains information about roughly 4,400 beers and 1,200 breweries. I converted the data to JSON so that it is easy to import into RethinkDB. There are two tables, one for beers and one for breweries. The application uses RethinkDB’s support for table joins to correlate the beers with their respective breweries.
BeerThink’s backend is built with Node.js and Express. It exposes beer and brewery data retrieved from a RethinkDB database, providing a paginated API that returns 50 records at a time.
The BeerThink frontend is built with Ionic, a popular AngularJS-based JavaScript framework designed for mobile web apps. BeerThink uses an infinite scrolling list to present the beers in alphabetical order.
BeerThink’s architecture aligns with the API-first approach used by many modern mobile web applications. The backend is solely an API layer, completely decoupled from the frontend. The frontend is a single-page web application designed to consumes the backend API. This particular approach makes it easy to build multiple frontend experiences on top of the same backend. You could, for example, easily make native desktop and mobile applications that consume the same backend API.
This tutorial demonstrates how BeerThink’s pagination works at each layer of the stack: the RethinkDB database, the Node backend, and the Ionic client application.
Efficient pagination in RethinkDB
If you’d like to follow along and try the pagination queries yourself, create a
table and then use the r.http
command to add the beer list to a database:
r.table("beers").insert(r.http("https://raw.githubusercontent.com/rethinkdb/beerthink/master/data/beers.json", {result_format: "json"}))
To efficiently alphabetize and paginate the beer list, you first need to create an index on the name property:
r.table("beers").indexCreate("name")
After creating the index, you can use it in the orderBy
command to fetch an
alphabetized list of names:
r.table("beers").orderBy({index: "name"})
When paginating records from a database, you want to be able to obtain a subset
of ordered table records. In a conventional SQL environment, you might
accomplish that by using OFFSET
and LIMIT
. RethinkDB’s skip
and limit
commands are serviceable equivalents, but the skip command doesn’t offer
optimal performance.
The between
command, which is commonly used to fetch all documents that are
between two keys in a table, is a much more efficient way to get the start
position of a table subset. You can optionally specify a secondary index when
using the between
command, which means that it can operate on the indexed
name
property of the beers
table.
The following example shows how to use the between
command on the name
index to get all of the beers between “Petrus Speciale” and “Plank Road Pale
Ale” in alphabetical order:
r.table("beers")
.between("Petrus Speciale", "Plank Road Pale Ale", {index: "name"})
.orderBy({index: "name"})
When the BeerThink application starts, it uses orderBy
and limit
to fetch
the first page of data. To get subsequent pages, it uses the between
and
limit
commands. The value that the program supplies for the between
command’s start position is simply the index of the very last item that was
fetched on the previous page.
r.table("beers")
.between("Petrus Speciale", null, {leftBound: "open", index: "name"})
.orderBy({index: "name"}).limit(50)
The example above shows how to fetch 50 records, starting from a particular
beer. Because the program doesn’t actually know what beer will be at the end of
the new page of data, the between
command is given null
as its closing
index value. That will cause the between
command to return everything from
the start index to the end of the table. The query uses the limit
command to
get only the desired number of records.
Setting the value of the leftBound
option to open
tells the between
command to omit the first record, the one that we use to define the start
index. That’s useful because the item is one that you already have at the end
of your list—you don’t want to add it again.
The slice command
The between
command is a good way to implement pagination in many cases, but
it isn’t universally applicable. There are cases where you won’t have the last
item of the previous page to use as a starting point.
Consider a situation where you want the user to be able to visit an arbitrary
page without first iterating through the entire set. You might, for example,
want to build a web application that accepts an arbitrary page number as a URL
path segment and returns the relevant results. In such cases, the best approach
is to use the slice
command.
The slice command takes a start index and an end index. To get 50 records that
are 3000 records down from the top of the table, simply pass 3000
and 3050
as the parameters:
r.table("beers").orderBy({index: "name"}).slice(3000, 3050)
When the user requests an arbitrary page, you simply multiply by the number of items per page to determine the slice command’s start and end positions:
query.slice((pageNumber - 1) * perPage, pageNumber * perPage)
In the example above, use the desired values for pageNumber
and perPage
.
Although the slice
command isn’t as fast as using between
and limit
, it
is still much more efficient than using the skip
command.
Pagination in BeerThink’s API backend
The BeerThink backend is built with Node and Express. It provides simple API
endpoints that are consumed by the frontend client application. The /beers
endpoint provides the list of beers, 50 records at a time. The application
also has a /breweries
endpoint that similarly displays a list of beers.
For pagination, the user can optionally pass a last
URL query parameter with
the name of the most recently-fetched item. Both API endpoints support the same
pagination mechanism. Taking advantage of the ReQL query language’s
composability, I generalized the operation that I use for pagination into a
function that I can apply to any table index:
function paginate(table, index, limit, last) {
return (!last ? table : table
.between(last, null, {leftBound: "open", index: index}))
.orderBy({index: index}).limit(limit)
}
The table
parameter takes a RethinkDB expression that references a table. The
index
parameter is the name of the table index on which to operate. The
limit
parameter is the total number of desired items. The last
parameter is
the item to use to find the start of the page. If the last
parameter is null
or undefined, the application will fetch the first page of data instead of
applying the between
command.
In the /breweries
endpoint, apply the paginate
function to the breweries
table. Use the req.param
method provided by Express to get the URL query
parameter that has the value of the last list item. If the user didn’t provide
the URL query parameter, the value will be undefined. All you have to do is run
the query and give the user the JSON results:
app.get("/breweries", function(req, res) {
var last = req.param("last");
paginate(r.table("breweries"), "name", 50, last).run(req.db)
.then(function(cursor) { return cursor.toArray(); })
.then(function(output) { res.json(output); })
.error(function(err) {
res.status(500).json({error: err});
});
});
The /beers
endpoint is implemented the exact same way as the /breweries
endpoint, using the same paginate
function that I defined above. The query is
a little more complex, however, because it has to use an eqJoin
operation to
get the brewery for each beer:
app.get("/beers", function(req, res) {
var last = req.param("last");
paginate(r.table("beers"), "name", 50, last)
.eqJoin("brewery_id", r.table("breweries"))
.map(function(item) {
return item("left").merge({"brewery": item("right")})
}).without("brewery_id").run(req.db)
.then(function(cursor) { return cursor.toArray(); })
.then(function(output) { res.json(output); })
.error(function(err) {
res.status(500).json({error: err});
});
});
Even though the two endpoints used different queries, the same pagination
function worked well on both. Abstracting common ReQL patterns into reusable
functions can greatly simplify your code. If you wanted to make it possible for
the client specify how many records are returned for each page, you could
easily achieve that by taking another request variable and passing it to the
paginate
function as the value of the limit
parameter.
Slice-style pagination on the backend
Although the between
command is the best approach to use for pagination in
the BeerThink application, the slice
command is also easy to implement on the
backend. I’ve included a brief explanation here for those who would like to see
an example.
When you define a URL handler in Express, you can use a colon to signify that a
particular URL segment is a variable. If you define the breweries endpoint as
/breweries/:page
, the page number passed by the user in the URL segment will
be assigned to the request’s page
parameter.
In the handler for the endpoint, use parseInt
or a plus sign to coerce the
page number into an integer that can be passed into the ReQL query. Next, use
the orderBy
command to alphabetize the breweries. Finally, use the slice
command with the page number and item count to fetch the desired subset of
items.
app.get("/breweries/:page", function(req, res) {
var pageNum = parseInt(req.params.page) || 1;
r.table("breweries").orderBy({index: "name"})
.slice((pageNum - 1) * 50, pageNum * 50).run(req.db)
.then(function(cursor) { return cursor.toArray(); })
.then(function(output) { res.json(output); })
.error(function(err) {
res.status(500).json({error: err});
});
});
If the user browses to /breweries/3
, the application will give them the third
page of brewery data formatted in JSON. In the example above, you might notice
that the code assigns a default value of 1
to the pageNum
variable if a
page number wasn’t provided with the request. That makes it so visiting
/breweries
by itself, without a page URL segment, will return the first page
of data.
Consuming the paginated API in Ionic
Now that the endpoint is defined, the client can simply iterate through the
pages as the user scrolls, adding each page of data to a continuous list. It’s
especially easy to accomplish with Ionic, because the framework includes an
AngularJS directive called ion-infinite-scroll
that you can use alongside any
list view to easily implement infinite scrolling:
<ion-content>
<ion-list>
<ion-item collection-repeat="beer in items" ...>
...
</ion-item>
</ion-list>
<ion-infinite-scroll on-infinite="fetchMore()" distance="25%">
</ion-infinite-scroll>
</ion-content>
In the markup above, the framework will execute the code in the on-infinite
attribute whenever the user scrolls to the position described in the distance
attribute. In this case, the application will call the fetchMore
method on
the active scope whenever the user scrolls within 25% of the list’s bottom.
In the associated AngularJS controller, the fetchMore
method uses the $http
service to retrieve the next page of data. It passes the name
property of the
most recently-fetched list item as the last
URL query parameter, telling the
backend which page to return.
app.controller("ListController", function($scope, $http) {
$scope.items = [];
var end = false;
$scope.fetchMore = function() {
if (end) return;
var count = $scope.items.length;
var params = count ? {"last": $scope.items[count-1].name} : {}
$http.get("/beers", {params: params}).success(function(items) {
if (items.length)
Array.prototype.push.apply($scope.items, items);
else end = true;
}).error(function(err) {
console.log("Failed to download list items:", err);
end = true;
}).finally(function() {
$scope.$broadcast("scroll.infiniteScrollComplete");
});
};
});
Each time that the fetchMore
function retrieves data, it appends the new
records to the items
scope variable. If the backend returns no data, the
application assumes that it has reached the end of the list and will stop
fetching additional pages. Similarly, it will stop fetching if it encounters an
error. In a real-world application, you might want to handle errors more
gracefully and make it so that the user can force a retry.
The ion-item
element in the HTML markup is bound to the items
array, which
means that new records will automatically display in the list. When I first
built the application, I originally implemented the repeating list item with
Angular’s ng-repeat
directive. I soon discovered that ng-repeat
doesn’t
scale very well to lists with thousands of items—scrolling performance wasn’t
very good and switching back from the beer detail view was positively glacial.
I eventually switched to Ionic’s relatively new
collection-repeat
directive, which is modeled after the cell reuse techniques that found in
native mobile frameworks. Adopting collection-repeat
substantially improved
scrolling performance and eliminated detail view lag. If you are building
mobile web apps with infinite scrolling lists that will house thousands of
items, I highly recommend collection-repeat
.
Going further
The application has a number of other features that are beyond the scope of this article, but you can get the source code from GitHub and have a look if you would like to learn more.
Install RethinkDB and check out the 10-minute intro guide to start building your first project.