Flutter Pagination with Riverpod: The Ultimate Guide

Source code on GitHub

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:

Screenshots of the example Movies app
Screenshots of the example Movies 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 and TMDBMovie model classes that represent the responses from the API.
  • Presentation layer: this contains custom widget classes such as MoviesListView and MovieListTile.

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:

Architecture diagram for the simplified pagination feature (no search)
Architecture diagram for the simplified pagination feature (no search)

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 between index and pageSize (adding 1 since the API requires pages to start from 1)
  • calculate the indexInPage as the modulo between index and pageSize

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:

Naive error UI: the error text appears 20 times for a given page
Naive error UI: the error text appears 20 times for a given page

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:

Improved error UI: the error text appears only once for a given page
Improved error UI: the error text appears only once for a given page

But we can do better!

How about showing a custom MovieListTileError with a Retry option that looks like this?

Optimal error UI: the error text appears only once for a given page, and includes a Retry button
Optimal error UI: the error text appears only once for a given page, and includes a Retry button

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

Once implemented, the refresh UI looks like this:

Pull-to-refresh UI
Pull-to-refresh UI

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 MovieListTiles 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 a MoviesSearchQueryNotifier 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:

Complete architecture for the pagination and search feature
Complete architecture for the pagination and search feature

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:

Complete architecture for the pagination and search feature
Complete architecture for the pagination and search feature

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:

Complete architecture for the pagination and search feature
Complete architecture for the pagination and search feature

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:

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