In mobile app development, fetching data efficiently is crucial for a smooth user experience. Imagine your app has to display a list of items fetched from an API. If the list is massive, retrieving all items at once could cause significant problems, including slow load times, high memory usage, and excessive data consumption. This is where pagination comes to the rescue.
Pagination divides large datasets into manageable chunks or pages, allowing the app to load a specific subset of data at a time. This approach reduces the initial payload and improves the user experience by offering a more responsive and faster UI. It mimics the way we naturally consume content—piece by piece rather than all at once. By implementing pagination, we ensure the app remains fast and efficient, even when dealing with extensive data sets.
But to implement an effective pagination strategy in your apps, there are many things to consider:
- How to load the first page and subsequent ones when the user scrolls down
- How to handle loading and errors states
- How to refresh the data
- How to add a search UI and return paginated results for different search queries
- How to optimize your pagination logic with caching and debouncing
This article will answer all these questions by covering both the concepts and code needed to build an efficient pagination strategy using Riverpod.
A quick search on pub.dev reveals a package called infinite_scroll_pagination, which is architecture, layout, and API-agnostic, making it a very flexible solution for your pagination needs. If you want to use it, you have my blessing. But if your app already uses Riverpod, an extra package (along with its dependencies) is not necessary, and as this article shows, rolling your own pagination code with Riverpod is not that hard.
Note: here, we will focus on pagination using futures, which is the most common use case if your app fetches data from a 3rd party REST API. Pagination with streams is beyond the scope of this article.
Ready? Let's go! 🚀
Example Flutter App with Pagination
In this article, you’ll learn how to build the search & pagination logic for a movie app:
The article is divided into two main parts:
- Simple Pagination: how to implement pagination with infinite scrolling, handle loading and error states, and add a pull-to-refresh UX.
- Pagination with Search: adding search to the mix, with improved caching and debouncing.
While the UI is tailored to this specific app, the underlying reusable logic is what matters the most, and that is what we will focus on.
Many of the concepts and code in this article borrow ideas from official Riverpod example apps such as Pub and Marvel. In writing this tutorial, I’ve covered some additional edge cases that must be considered for a production-ready implementation. I’ll share the complete code at the end.
Pagination API Basics
Suppose you want to use the TMDB API to get a list of movies that are currently playing.
To do this, you can obtain an API key and make a request to this endpoint:
curl --request GET \
--url 'https://api.themoviedb.org/3/movie/now_playing?language=en-US&page=1' \
--header 'Authorization: Bearer <YOUR_ACCESS_TOKEN>' \
--header 'accept: application/json'
In the response, you’ll get the following JSON:
{
"dates": {
"maximum": "2024-04-17",
"minimum": "2024-03-06"
},
"page": 1,
"results": [
...
],
"total_pages": 201,
"total_results": 4004
}
As you can see, the request above returned 4004 results and fetching them all at once would be very inefficient.
For this reason, the API takes a page
argument that we can use to fetch a specific page.
As a result, we can consider this approach when building our app:
- On the first load, fetch the first page and show the results
- Once we scroll to the bottom, fetch the next page, and so on
This seems like a reasonable strategy, but we have some questions to answer:
- How do we keep track of the current and next page index?
- Where should the results be stored?
- What event should we use to trigger the next page load?
- How do we deal with loading and error states?
- How does our system architecture work as a whole?
Time to dig a bit deeper. 👇
Offset-Based Pagination with Flutter and Riverpod
When building new apps or features, we need a good separation of concerns between data and UI.
If we use my Riverpod architecture, we can identify three layers with the following classes:
- Data layer: this contains the
MoviesRepository
that we will use to interact with the API. - Domain layer: this contains the
TMDBMoviesResponse
andTMDBMovie
model classes that represent the responses from the API. - Presentation layer: this contains custom widget classes such as
MoviesListView
andMovieListTile
.
By using the classes above, we can build a minimal yet functional UI with pagination.
As this diagram shows, we’ll also need a fetchMoviesProvider
to cache the results returned from the API:
Let’s take a closer look.
Data Layer: The MoviesRepository
The simplest implementation of our MoviesRepository
looks like this:
class MoviesRepository {
const MoviesRepository({required this.client, required this.apiKey});
final Dio client;
final String apiKey;
Future<TMDBMoviesResponse> nowPlayingMovies({required int page}) async {
final uri = Uri(
scheme: 'https',
host: 'api.themoviedb.org',
path: '3/movie/now_playing',
queryParameters: {
'api_key': apiKey,
'include_adult': 'false',
'page': '$page',
},
);
final response = await client.getUri(uri);
return TMDBMoviesResponse.fromJson(response.data);
}
}
A few notes:
- We use the dio package for networking
- When making requests to the TMDB API, we need to pass an API key
- The page index is passed as an argument to the
nowPlayingMovies
method - The response data is parsed into a
TMDBMoviesResponse
object (we’ll review this shortly)
The fetchMoviesProvider
Alongside our repository, we also need a fetchMoviesProvider
, which can be generated from this function:
@riverpod
Future<TMDBMoviesResponse> fetchMovies(FetchMoviesRef ref, int page) {
final moviesRepo = ref.watch(moviesRepositoryProvider);
return moviesRepo.nowPlayingMovies(page: page);
}
We’ll see how to use this provider shortly.
But for now, note that thanks to the page
argument, we can fetch and cache the results for each page (under the hood, Riverpod will generate a family of providers).
Domain Layer: Data Models
The response JSON from the API contains many different fields, and we can use the freezed package to convert them to type-safe model classes:
@freezed
class TMDBMoviesResponse with _$TMDBMoviesResponse {
factory TMDBMoviesResponse({
required int page,
required List<TMDBMovie> results,
@JsonKey(name: 'total_results') required int totalResults,
@JsonKey(name: 'total_pages') required int totalPages,
@Default([]) List<String> errors,
}) = _TMDBMoviesResponse;
factory TMDBMoviesResponse.fromJson(Map<String, dynamic> json) =>
_$TMDBMoviesResponseFromJson(json);
}
The TMDBMoviesResponse
contains useful properties such as page
, totalResults
, totalPages
, along with a list of results of type TMDBMovie
, which is defined as follows:
@freezed
class TMDBMovie with _$TMDBMovie {
factory TMDBMovie({
required int id,
required String title,
@JsonKey(name: 'poster_path') String? posterPath,
@JsonKey(name: 'release_date') String? releaseDate,
}) = _TMDBMovieBasic;
factory TMDBMovie.fromJson(Map<String, dynamic> json) =>
_$TMDBMovieFromJson(json);
}
The TMDB movies API returns many more fields, but these are the ones we need in the UI.
Presentation Layer: Flutter Pagination with Infinite Scrolling
To show all the movies in the UI, we can implement a custom MoviesListView
widget:
class MoviesListView extends ConsumerWidget {
const MoviesListView({super.key});
static const pageSize = 20;
@override
Widget build(BuildContext context, WidgetRef ref) {
return ListView.builder(
itemBuilder: (context, index) {
final page = index ~/ pageSize + 1;
final indexInPage = index % pageSize;
// use the fact that this is an infinite list to fetch a new page
// as soon as the index exceeds the page size
// Note that ref.watch is called for up to pageSize items
// with the same page and query arguments (but this is ok since data is cached)
final AsyncValue<TMDBMoviesResponse> responseAsync = ref.watch(fetchMoviesProvider(page));
return responseAsync.when(
error: (err, stack) => Text(err.toString()),
loading: () => const MovieListTileShimmer(),
data: (response) {
// This condition only happens if a null itemCount is given
if (indexInPage >= response.results.length) {
return null;
}
final movie = response.results[indexInPage];
return MovieListTile(
movie: movie,
debugIndex: index + 1,
);
},
);
},
);
}
}
The build
method is a mere 25 lines long (with comments).
Since the TMDB API returns paginated data with a fixed page size, we can define this constant:
static const pageSize = 20;
And inside the itemBuilder
, some magic happens:
itemBuilder: (context, index) {
final page = index ~/ pageSize + 1;
final indexInPage = index % pageSize;
...
}
Here’s what this does:
- calculate the
page
index as the integer division betweenindex
andpageSize
(adding1
since the API requires pages to start from 1) - calculate the
indexInPage
as the modulo betweenindex
andpageSize
Note: if your API does not return fixed-size pages, this approach will not work. Read the section below about cursor-based pagination for more info.
Then, we fetch the response for the given page by watching the fetchMoviesProvider
for the given page:
final AsyncValue<TMDBMoviesResponse> responseAsync = ref.watch(fetchMoviesProvider(page));
Finally, we map the response to the UI by handling the error, loading, and data states:
return responseAsync.when(
error: (err, stack) => Text(err.toString()),
loading: () => const MovieListTileShimmer(),
data: (response) {
// This condition only happens if a null itemCount is given
if (indexInPage >= response.results.length) {
return null;
}
final movie = response.results[indexInPage];
return MovieListTile(
movie: movie,
debugIndex: index + 1,
);
},
);
Note how, in the data
callback, we use the indexInPage
to retrieve the movie object, which is then passed to the MovieListTile
.
But what about this check?
if (indexInPage >= response.results.length) {
return null;
}
What does it do, and why do we need it?
Well, if we take a closer look at our ListView.builder
, we can see that we have not passed an itemCount
:
ListView.builder(
// itemCount is omitted
itemBuilder: (context, index) {
final page = index ~/ pageSize + 1;
final indexInPage = index % pageSize;
final AsyncValue<TMDBMoviesResponse> responseAsync =
ref.watch(fetchMoviesProvider(page));
return responseAsync.when(
error: (err, stack) => Text(err.toString()),
loading: () => const MovieListTileShimmer(),
data: (response) {
// This condition only happens if a null itemCount is given
if (indexInPage >= response.results.length) {
return null;
}
final movie = response.results[indexInPage];
return MovieListTile(
movie: movie,
debugIndex: index + 1,
);
},
);
},
)
Guess what?
It is perfectly valid to omit the itemCount
. When we do so, Flutter will continue to add items to the ListView
and render them as we scroll until the itemBuilder
returns null
(this behaviour is very useful if we want to implement infinite scrolling).
But since our movies dataset is finite, we want to stop rendering items when we reach the end of the list, and that is what this code is for:
if (indexInPage >= response.results.length) {
return null;
}
When I first saw code like this in the official Riverpod examples, I was a bit confused:
itemBuilder: (context, index) {
final page = index ~/ pageSize + 1;
final indexInPage = index % pageSize;
...
}
So, allow me to clarify how it all works with an example.
Understanding the Flutter Pagination Index Logic
To better understand what happens at runtime, we can add a log to our itemBuilder
:
itemBuilder: (context, index) {
final page = index ~/ pageSize + 1;
final indexInPage = index % pageSize;
log('index: $index, page: $page, indexInPage: $indexInPage');
...
}
When we run the app and trigger the first page load, we’ll see the following logs:
[log] index: 0, page: 1, indexInPage: 0
[log] index: 1, page: 1, indexInPage: 1
[log] index: 2, page: 1, indexInPage: 2
[log] index: 3, page: 1, indexInPage: 3
[log] index: 4, page: 1, indexInPage: 4
...
If we keep scrolling down, we’ll eventually trigger the second page load:
[log] index: 19, page: 1, indexInPage: 19
[log] index: 20, page: 2, indexInPage: 0
[log] index: 21, page: 2, indexInPage: 1
[log] index: 22, page: 2, indexInPage: 2
[log] index: 23, page: 2, indexInPage: 3
[log] index: 24, page: 2, indexInPage: 4
And if we continue scrolling, the subsequent pages will also be loaded.
Note that while indexInPage
changes for each line, the page
only changes every 20 items (since it’s calculated as an integer division).
Now, let’s not forget that this code runs for every item inside the list:
itemBuilder: (context, index) {
final page = index ~/ pageSize + 1;
final indexInPage = index % pageSize;
log('index: $index, page: $page, indexInPage: $indexInPage');
final AsyncValue<TMDBMoviesResponse> responseAsync =
ref.watch(fetchMoviesProvider(page));
...
}
This means that we watch the fetchMoviesProvider
with the same page
argument up to 20 times for a given page.
But under the hood, we’re only making one API request (not 20) because Riverpod is doing all the caching for us. Sweet!
Handling the Loading and Error states
While a page request is in progress, all the ListView
items for that page will return a MovieListTileShimmer
widget:
return responseAsync.when(
loading: () => const MovieListTileShimmer(),
error: (err, stack) => Text(err.toString()),
data: ...
);
Likewise, if the API returns an error, all the ListView
items will show an error text:
This is not ideal, but it can be easily improved with some conditional logic:
// * Only show error on the first item of the page
error: (err, stack) => indexInPage == 0
? Text(err.toString())
: const SizedBox.shrink(),
As a result, if we get an error in the API response, our UI will look like this:
But we can do better!
How about showing a custom MovieListTileError
with a Retry option that looks like this?
Here’s the widget class that makes this possible:
class MovieListTileError extends ConsumerWidget {
const MovieListTileError({
super.key,
required this.query,
required this.page,
required this.indexInPage,
required this.isLoading,
required this.error,
});
final String query;
final int page;
final int indexInPage;
final bool isLoading;
final String error;
@override
Widget build(BuildContext context, WidgetRef ref) {
// * Only show error on the first item of the page
return indexInPage == 0
? Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(error),
ElevatedButton(
onPressed: isLoading
? null
: () {
// invalidate the provider for the errored page
ref.invalidate(fetchMoviesProvider(page));
// wait until the page is loaded again
return ref.read(fetchMoviesProvider(page).future);
},
child: const Text('Retry'),
),
],
),
)
: const SizedBox.shrink();
}
}
The retry button allows us to invalidate and refresh only the page that failed loading (rather than reloading everything). Simple and elegant!
This is good progress, but we still have one important problem to solve.
What happens when we run out of items?
What happens once we reach the last page and the end of our paginated data?
As it turns out, our code works well and stops loading new pages as soon as we return null
here:
if (indexInPage >= movies.results.length) {
return null;
}
However, if we reach the bottom of the list and try to overscroll, we’ll see that Flutter calls the itemBuilder
repeatedly until the animation stops:
>>> 28 TIMES <<< [log] index: 25, page: 2, indexInPage: 5
To avoid this, we can watch the first page outside the ListView
, obtain the total number of results, and pass this to the itemCount
property:
// get the first page so we can retrieve the total number of results
final responseAsync = ref.watch(fetchMoviesProvider(1));
final totalResults = responseAsync.valueOrNull?.totalResults;
return ListView.builder(
// pass the itemCount explicitly to prevent unnecessary renders
// during overscroll
itemCount: totalResults,
itemBuilder: (context, index) {
...
},
)
With this optimization in place, Flutter will only call the itemBuilder
and render items that are within the pagination range.
Flutter Pagination for Production Apps
The code above already gives us a working solution.
But if we want to use it in production, we have to go further:
- How to refresh the paginated data?
- How to cache our previous ages and prevent unnecessary API requests as we scroll?
- How to implement an efficient search UX with caching and debouncing?
Let’s tackle these one by one.
Refreshing the Paginated Data
If we don’t have a real-time connection to the server, we will not know if there is new data unless we refresh it.
In this scenario, it’s common to offer a pull-to-refresh UI that we can use to trigger a refresh.
And in Flutter, this is easily accomplished with a RefreshIndicator
:
class MoviesListView extends ConsumerWidget {
const MoviesListView({super.key});
static const pageSize = 20;
@override
Widget build(BuildContext context, WidgetRef ref) {
final responseAsync = ref.watch(fetchMoviesProvider(1));
final totalResults = responseAsync.valueOrNull?.totalResults;
// wrap our ListView.builder with a RefreshIndicator
return RefreshIndicator(
onRefresh: () async {
// dispose all the pages previously fetched. Next read will refresh them
ref.invalidate(fetchMoviesProvider);
// keep showing the progress indicator until the first page is fetched
try {
await ref.read(
fetchMoviesProvider(queryData: (page: 1, query: query))
.future,
);
} catch (e) {
// fail silently as the provider error state is handled inside the ListView
}
},
child: ListView.builder(...),
);
}
}
Inside the onRefresh
callback, we do two things:
- Invalidate all the previous pages (remember that
fetchMoviesProvider
is a family of providers). - Fetch the first page again and
await
its completion. If an error is thrown, we fail silently as the provider error state is handled elsewhere inside theListView
.
Once implemented, the refresh UI looks like this:
And if we can scroll down after a pull-to-refresh, each subsequent page will be fetched anew.
How to Cache the Paginated Data in Flutter?
Remember when I said that when the itemBuilder
is called, we only make one API request (and not 20) because Riverpod is doing all the caching for us?
Well, that holds true while rendering all the items for a given page.
But what happens if we scroll down and the MovieListTile
s of the previous pages go completely offscreen and become unmounted?
Well, the answer lies in how this provider is declared:
@riverpod
Future<TMDBMoviesResponse> fetchMovies(FetchMoviesRef ref, int page) {
final moviesRepo = ref.watch(moviesRepositoryProvider);
return moviesRepo.nowPlayingMovies(page: page);
}
Note how we’re using the @riverpod
annotation (and not @Riverpod(keepAlive: true)
).
This means that as soon as all the provider listeners (our ListView
items) are removed, the provider itself will be disposed and no longer hold on to the cached data.
To overcome this, we could set @Riverpod(keepAlive: true)
, but that would cause all the previously cached data to remain in memory for as long as the app is running. On mobile, this can cause memory pressure and prompt the OS to kill our app. 🙁
As Shakespeare would put it: to auto-dispose or to not auto-dispose?
Riverpod Caching with Timeout
A better solution sits somewhere in the middle: cache each page for a given duration using a Timer
and only dispose the provider if the Timer
expires.
Here’s how to do this:
@riverpod
Future<TMDBMoviesResponse> fetchMovies(FetchMoviesRef ref, int page) {
final moviesRepo = ref.watch(moviesRepositoryProvider);
// See this for how the timeout is implemented:
// https://codewithandrea.com/articles/flutter-riverpod-data-caching-providers-lifecycle/#caching-with-timeout
final cancelToken = CancelToken();
// When a page is no-longer used, keep it in the cache.
final link = ref.keepAlive();
// Declare a timer to be used by the callbacks below
Timer? timer;
// When the provider is destroyed, cancel the http request and the timer
ref.onDispose(() {
cancelToken.cancel();
timer?.cancel();
});
// When the last listener is removed, start the timer
ref.onCancel(() {
timer = Timer(const Duration(seconds: 30), () {
// Dispose the cached data on timeout
link.close();
});
});
// If the provider is listened again after it was paused, cancel the timer
ref.onResume(() {
timer?.cancel();
});
// Pass the CancelToken to the API
return moviesRepo.nowPlayingMovies(page: page, cancelToken: cancelToken);
}
If you’re not familiar with the provider lifecycle callbacks, read: Riverpod Data Caching and Providers Lifecycle: Full Guide
Note that in addition to the Timer
code in the provider lifecycle callbacks, we also create a CancelToken
and pass it as an argument to the nowPlayingMovies
method. This lets us cancel any in-flight page requests that are no longer needed (for example, because we’re scrolling very fast and go past the page being loaded before the API returns a response).
Simple Pagination: Mission Accomplished
Here’s what we have accomplished so far:
- Created a
MoviesRepository
and the model classes for fetching movies with a paginated API - Implemented a custom
MoviesListView
widget that supports infinite scrolling and loads new pages as we scroll - Added per-page support for the loading and error states (with retry)
- Added a pull-to-refresh UI so we can reload all the data from the server
- Added a timeout-based caching strategy to our provider so we can keep the previous pages in memory
Not bad at all. But let’s keep pushing! 🚀
Flutter Pagination with Search
Loading and showing paginated data is useful.
But if we’re dealing with hundreds or thousands of items, adding a “search-as-you-type” feature will make it easier to find the data we need.
How can we implement this and build upon the pagination logic we added so far?
Here’s a high-level overview:
- Data layer: Inside our
MoviesRepository
, add a new method that takes a search query and the page number and uses them to call a search API endpoint. - Presentation layer: add a
MoviesSearchBar
widget that stores the updated search query inside aMoviesSearchQueryNotifier
when the text changes. - Provider: update the
fetchMoviesProvider
so it takes the search query into account when fetching the data from the repository.
Here’s a diagram showing how it all fits together:
Now, let’s take a closer look at all these classes.
MoviesRepository with Search
In order to use the TMDB movie search endpoint, we can add a new searchMovies
method to our MoviesRepository
:
/// Metadata used when fetching movies with the paginated search API.
typedef MoviesQueryData = ({String query, int page});
class MoviesRepository {
const MoviesRepository({required this.client, required this.apiKey});
final Dio client;
final String apiKey;
// new method
Future<TMDBMoviesResponse> searchMovies(
{required MoviesQueryData queryData, CancelToken? cancelToken}) async {
final uri = Uri(
scheme: 'https',
host: 'api.themoviedb.org',
path: '3/search/movie',
queryParameters: {
'api_key': apiKey,
'include_adult': 'false',
'page': '${queryData.page}',
'query': queryData.query,
},
);
final response = await client.getUri(uri, cancelToken: cancelToken);
return TMDBMoviesResponse.fromJson(response.data);
}
// old method
Future<TMDBMoviesResponse> nowPlayingMovies(
{required int page, CancelToken? cancelToken}) { ... }
}
Note how we’ve introduced a new MoviesQueryData
record typedef, which contains the page
and query
arguments needed by the API.
If you’re not familiar with records in Dart, read: What’s New in Dart 3: Introduction.
Updated MoviesSearchScreen UI
Here’s the complete widget class that adds a new MoviesSearchBar
widget above our ListView.builder
(inside a Column
):
class MoviesSearchScreen extends ConsumerWidget {
const MoviesSearchScreen({super.key});
static const pageSize = 20;
@override
Widget build(BuildContext context, WidgetRef ref) {
// get the search query from a separate provider
final query = ref.watch(moviesSearchQueryNotifierProvider);
final responseAsync = ref.watch(
// pass both the page and query to the fetchMoviesProvider
fetchMoviesProvider(queryData: (page: 1, query: query)),
);
final totalResults = responseAsync.valueOrNull?.totalResults;
return Scaffold(
appBar: AppBar(title: const Text('TMDB Movies')),
body: Column(
children: [
// a new widget class that controls the moviesSearchQueryNotifierProvider
const MoviesSearchBar(),
Expanded(
child: RefreshIndicator(
onRefresh: () {
ref.invalidate(fetchMoviesProvider);
return ref.read(
// pass both the page and query to the fetchMoviesProvider
fetchMoviesProvider(queryData: (page: 1, query: query)).future,
);
},
child: ListView.builder(
// use a different key for each query, ensuring the scroll
// position is reset when the query and results change
key: ValueKey(query),
itemCount: totalResults,
itemBuilder: (context, index) {
final page = index ~/ pageSize + 1;
final indexInPage = index % pageSize;
final responseAsync = ref.watch(
// pass both the page and query to the fetchMoviesProvider
fetchMoviesProvider(queryData: (page: page, query: query)),
);
return responseAsync.when(...);
},
),
),
),
],
),
);
}
}
One thing to note is that we’re now passing a key
argument to our ListView
:
ListView.builder(
// use a different key for each query, ensuring the scroll
// position is reset when the query and results change
key: ValueKey(query),
...
)
This ensures the scroll position is reset when the query (and corresponding results) change.
Updated fetchMoviesProvider
The provider now takes a MoviesQueryData
object that contains the page
and query
inputs:
/// Provider to fetch paginated movies data
@riverpod
Future<TMDBMoviesResponse> fetchMovies(
FetchMoviesRef ref, {
required MoviesQueryData queryData,
}) async {
final moviesRepo = ref.watch(moviesRepositoryProvider);
// all the provider lifecycle callbacks (same as before)
// ...
if (queryData.query.isEmpty) {
// Non-search endpoint
return moviesRepo.nowPlayingMovies(
page: queryData.page,
cancelToken: cancelToken,
);
} else {
// Search endpoint
return moviesRepo.searchMovies(
queryData: queryData,
cancelToken: cancelToken,
);
}
}
As we can see:
- if the search query is empty, we fetch data with the
nowPlayingMovies
method - otherwise, we fetch data with the new
searchMovies
method
This is desirable because if we call the search endpoint with an empty query, the TMDB API returns an empty list of results. Instead, by calling the nowPlayingMovies
method, we always get some results that we can show.
MoviesSearchBar and MoviesSearchQueryNotifier
The MoviesSearchBar
is a custom ConsumerStatefulWidget
that contains a TextField
that is used as the search input.
Leaving the styling code aside, the most relevant part is this:
TextField(
onChanged: (text) => ref
.read(moviesSearchQueryNotifierProvider.notifier)
.setQuery(text),
);
Here’s the underlying Riverpod notifier that holds onto our search query:
/// A simple notifier class to keep track of the search query
@riverpod
class MoviesSearchQueryNotifier extends _$MoviesSearchQueryNotifier {
@override
String build() {
// by default, return an empty query
return '';
}
void setQuery(String query) {
state = query;
}
}
Once again, all these classes fit together like this:
The green arrows in the diagram highlight two important concerns:
- Caching: as we observed before, all the caching logic is inside the
fetchMoviesProvider
, ensuring we don’t make repeated calls to the server endpoint. - Debouncing: needed to avoid calling the server API too often while we’re typing the search query.
So, let’s look at debouncing more in detail.
Avoiding too many API calls with Debouncing
Let’s recall that our search text field callback updates the notifier state every time the text changes:
TextField(
onChanged: (text) => ref
.read(moviesSearchQueryNotifierProvider.notifier)
.setQuery(text),
);
And since we watch the moviesSearchQueryNotifierProvider
inside our MoviesSearchScreen
, this widget also rebuilds when the text changes:
class MoviesSearchScreen extends ConsumerWidget {
const MoviesSearchScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// rebuild every time the text changes
final query = ref.watch(moviesSearchQueryNotifierProvider);
// get the first page so we can retrieve the total number of results
final responseAsync = ref.watch(
fetchMoviesProvider(queryData: (page: 1, query: query)),
);
final totalResults = responseAsync.valueOrNull?.totalResults;
...
}
}
This is necessary because we need to know both the page and query when we watch the fetchMoviesProvider
. In fact, we do the same thing inside the itemBuilder
:
ListView.builder(
key: ValueKey(query),
itemCount: totalResults,
itemBuilder: (context, index) {
final page = index ~/ pageSize + 1;
final indexInPage = index % pageSize;
final responseAsync = ref.watch(
// page and query passed as arguments
fetchMoviesProvider(queryData: (page: page, query: query)),
);
return responseAsync.when(...);
},
}
And if the queryData
is not already cached inside our provider, we end up making a network API request every time the text changes. That’s not good.
Debouncing Implementation
To prevent this, we can modify our MoviesSearchQueryNotifier
to debounce all input queries with this code:
/// A notifier class to keep track of the search query (with debouncing)
@riverpod
class MoviesSearchQueryNotifier extends _$MoviesSearchQueryNotifier {
/// Used to debounce the input queries
Timer? _debounceTimer;
@override
String build() {
// don't forget to close the StreamController and cancel the subscriptions on dispose
ref.onDispose(() {
_debounceTimer?.cancel();
});
// by default, return an empty query
return '';
}
void setQuery(String query) {
log('debounce query: $query');
// Cancel the timer if it is active
if (_debounceTimer != null) {
_debounceTimer!.cancel();
}
_debounceTimer = Timer(const Duration(milliseconds: 500), () {
log('debounce state: $query');
// only update the state once the query has been debounced
state = query;
});
}
}
To demonstrate how this code works, I’ve added some logs to the setQuery
method.
Then, I ran the app and entered “pizza” in the search box. Here are the output logs:
[log] ✅ 200 ✅ | 109ms | https://api.themoviedb.org/3/movie/now_playing?api_key=<API_KEY>&include_adult=false&page=1
[log] debounce query: p
[log] debounce query: pi
[log] debounce query: piz
[log] debounce query: pizz
[log] debounce query: pizza
[log] debounce state: pizza
[log] 🌍 Making request: https://api.themoviedb.org/3/search/movie?api_key=<API_KEY>&include_adult=false&page=1&query=pizza
[log] -------------------------
[log] ✅ 200 ✅ | 168ms | https://api.themoviedb.org/3/search/movie?api_key=<API_KEY>&include_adult=false&page=1&query=pizza
As we can see, all the intermediate text changes got logged, but the API request was only triggered after I stopped typing for 500ms.
Flutter Pagination with Search: Mission Accomplished
Our example app is now complete with the following features:
- Search for movies with pagination and a nice “search-as-you-type” UX
- Caching and debouncing to minimize widget rebuilds and API calls
We also have a nice separation of concerns between widgets and repositories, with Riverpod providers acting as the glue that holds everything together:
Common Questions
Before we wrap up, let me address some common questions you may have.
How to Implement Offline Caching?
The implementation presented here shows how to do in-memory caching using Riverpod providers. This means that if the app is killed and restarted, the data will be fetched from the server once again.
If you want to persist the server response data across app restarts, you’ll need to use some form of local storage using packages like Drift, along with a more custom caching strategy, and this is beyond the scope of this article.
How to add Search Filters?
It’s quite common for many apps (e.g. eCommerce, travel) to show a set of search filters in addition to the input query.
This can be done by adding more properties to the MoviesQueryData
object. Example:
typedef MoviesQueryData = ({String query, int minRating, int releaseYear, int page});
And guess what? Since the MoviesQueryData
is given as an argument to the fetchMovies
provider, Riverpod will cache the responses for each combination of properties. 🚀
What about Realtime Data?
If your app connects to a realtime DB, can you still use the same approach and enable pagination with realtime data?
In theory, this may be possible by using a StreamProvider
rather than a FutureProvider
, but I have not tried it, and I can think of some edge cases that will make this challenging (e.g. if items are added/removed, a fixed page size strategy is unlikely to work well).
Besides, popular Search-as-a-Service solutions like Algolia offer only Future-based search APIs. This makes sense since you only need to use one-time reads when searching for data (the data won’t change faster than you can type).
If you need to show real-time data from Firestore using pagination, read: Firestore Pagination Made Easy with FirestoreListView in Flutter.
What about Cursor-Based Pagination?
The TMDB API uses a page index and a fixed page size of 20 (this is known as offset-based pagination), and the approach described here relies on it:
static const pageSize = 20;
...
itemBuilder: (context, index) {
final page = index ~/ pageSize + 1;
final indexInPage = index % pageSize;
...
}
But other APIs may use cursor-based pagination, which is more efficient and scalable when paginating large datasets that can change frequently, such as social media feeds or live financial data. This method works by providing a "cursor" or pointer to a specific item in the dataset, and subsequent queries for data use this cursor to fetch the next (or previous) set of items relative to the cursor's position.
Since cursor-based pagination does not use a fixed page size, you'll need to modify the page and index calculation logic before you can reuse the technique explained here.
What about the ListView.builder null issue?
When working with ListView.builder
and pagination with Riverpod, you may come across this issue:
This is a Flutter-specific issue that can happen when returning null
from the itemBuilder
, however I have not encountered this while implementing my example app. And it should definitely not happen if a non-null itemCount
is provided.
Source Code (Flutter Movies App)
This article covered the most important concepts and code, leaving out less important details such as UI styling, API key setup, and overall code organisation.
If you want to follow the same approach and implement pagination in your apps, feel free to use this complete example as a reference:
Conclusion
Search with pagination is a relatively complex feature that is made of many moving parts:
- A repository that we can use to fetch the paginated data from the API
- A
FutureProvider
used to cache the API responses - All the widgets needed to show the search UI and the list of results (including different widgets for the data, loading, and error states)
- A custom
Notifier
subclass, used to debounce the input queries
By adding caching and debouncing and paying attention to important details, we ended up with a robust and efficient implementation that minimizes the load on the server.
While we could have built this feature with any state management solution (the infinite_scroll_pagination package proves this), Riverpod was extremely flexible for this use case, thanks to its reactive nature and customizable caching support.
Once again, you can grab my reference app here, and I hope it will make life easier when implementing pagination in your own apps.
Happy coding!
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: