Flutter Pagination with Riverpod: The Ultimate Guide

This comprehensive guide explores efficient data fetching strategies, focusing on pagination in mobile app development with Flutter and Riverpod. Covering key considerations, implementation techniques, and caching strategies, it equips developers with the tools to optimize performance and enhance user experience.

source code

Table of Contents

In mobile app development, efficiently fetching data is essential for ensuring a smooth user experience. When dealing with large datasets obtained from APIs, fetching all items at once can lead to various issues such as slow load times, high memory usage, and excessive data consumption.

Join Our Whatsapp Group

Join Telegram group

The Role of Pagination

Pagination offers a solution by dividing large datasets into manageable chunks or pages, allowing the app to load a specific subset of data at a time. This approach improves the user experience by reducing the initial payload, resulting in a more responsive and faster UI. It mirrors the way users naturally consume content—piece by piece rather than all at once.

Considerations for Implementing Pagination

To implement an effective pagination strategy in your apps, several factors must be considered:

Loading Mechanism

  • How to load the first page and subsequent ones when the user scrolls down

Handling States

  • How to handle loading and error states

Data Refresh

  • How to refresh the data

Search Functionality

  • How to add a search UI and return paginated results for different search queries

Optimization Techniques

  • How to optimize pagination logic with caching and debouncing

Exploring Riverpod for Pagination

This article will address these considerations by providing insights into both the concepts and code necessary to build an efficient pagination strategy using Riverpod.

tmdb movies screenshot

Leveraging Existing Solutions or Rolling Your Own

While a package like infinite_scroll_pagination offers a flexible solution for pagination needs, leveraging Riverpod can be advantageous if your app is already built with it. This article demonstrates that rolling your own pagination code with Riverpod is feasible and not overly complex.

Focus on Pagination with Futures

This article primarily focuses on pagination using futures, which is the most common scenario when fetching data from a third-party REST API. Pagination with streams is not covered in this article.

Building a Flutter Movie App with Pagination

In this article, we’ll delve into the process of constructing a movie app with search and pagination functionalities. The content is divided into two main sections:

Simple Pagination

Learn how to implement pagination with infinite scrolling, manage loading and error states, and incorporate a pull-to-refresh user experience.

Enhance the pagination setup by integrating search functionality, with a focus on improved caching and debouncing.

While the UI examples are tailored to our movie app, the core reusable logic is the primary focus of our discussion.

Leveraging Existing Concepts and Code

This tutorial draws inspiration from official Riverpod example apps like Pub and Marvel. While covering essential concepts, we also address additional edge cases crucial for a production-ready implementation. Complete code samples will be provided at the end for reference.

Join Our Whatsapp Group

Join Telegram group

Understanding Pagination API Basics

Let’s consider using the TMDB API to retrieve a list of currently playing movies. By obtaining an API key, you can make a request to the following 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'

The response from this request will contain JSON data structured as follows:

{
  "dates": {
    "maximum": "2024-04-17",
    "minimum": "2024-03-06"
  },
  "page": 1,
  "results": [
    ...
  ],
  "total_pages": 201,
  "total_results": 4004
}

This response highlights the need for pagination, as fetching 4004 results at once would be highly inefficient. The API allows us to specify a page parameter to fetch specific pages of data.

Key Questions to Address

As we embark on building our app, several key questions arise:

  • How do we manage current and next page indices?
  • Where should the retrieved results be stored?
  • What triggers the loading of subsequent pages?
  • How do we handle loading and error states effectively?
  • What is the overall system architecture?

Let’s delve into these questions to establish a robust pagination strategy. 👇

Implementing Offset-Based Pagination with Flutter and Riverpod

When developing new apps or features, it’s essential to maintain a clear separation of concerns between data and UI components.

Riverpod Architecture Overview

In our Riverpod-based architecture, we can identify three distinct layers:

simple pagination diagram

Data Layer

This layer encapsulates the MoviesRepository, responsible for interacting with the API.

Domain Layer

Here, we define the TMDBMoviesResponse and TMDBMovie model classes, representing API responses.

Presentation Layer

This layer consists of custom widget classes like MoviesListView and MovieListTile.

By utilizing these layers, we can construct a streamlined UI with pagination functionality.

Supporting Components

Additionally, our architecture requires a fetchMoviesProvider to cache API results for efficient pagination.

Exploring the Data Layer: MoviesRepository

The MoviesRepository serves as the backbone of our data layer. Here’s a basic implementation:

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);
  }
}

Notes:

  • We utilize the dio package for networking tasks.
  • TMDB API requests require an API key.
  • The nowPlayingMovies method accepts a page index as an argument.
  • Response data is parsed into a TMDBMoviesResponse object.

Implementing the fetchMoviesProvider

In addition to our repository, we require a fetchMoviesProvider, which can be generated using the following function:

@riverpod
Future<TMDBMoviesResponse> fetchMovies(FetchMoviesRef ref, int page) {
  final moviesRepo = ref.watch(moviesRepositoryProvider);
  return moviesRepo.nowPlayingMovies(page: page);
}

This provider, utilizing a page argument, enables us to fetch and cache results for each page. Under the hood, Riverpod generates a family of providers to manage this efficiently.

Domain Layer: Data Models

To handle the response JSON from the API effectively, we use the freezed package to convert it into 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 includes properties such as page, totalResults, totalPages, and a list of results of type TMDBMovie. The TMDBMovie model 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);
}

While the TMDB API returns additional fields, these are the ones pertinent to our UI.

Presentation Layer: Flutter Pagination with Infinite Scrolling

To showcase the movies in the UI, we can create 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;
        // Fetch data for the page as needed
        final AsyncValue<TMDBMoviesResponse> responseAsync = ref.watch(fetchMoviesProvider(page));
        return responseAsync.when(
          error: (err, stack) => Text(err.toString()),
          loading: () => const MovieListTileShimmer(),
          data: (response) {
            // Handle data accordingly
            if (indexInPage >= response.results.length) {
              return null;
            }
            final movie = response.results[indexInPage];
            return MovieListTile(
              movie: movie,
              debugIndex: index + 1,
            );
          },
        );
      },
    );
  }
}

Understanding Pagination Logic

In the itemBuilder, we calculate the page index and the indexInPage for each item:

itemBuilder: (context, index) {
  final page = index ~/ pageSize + 1;
  final indexInPage = index % pageSize;
  ...
}

This logic allows us to manage pagination seamlessly:

  • The page index is calculated based on the item index divided by the page size, incremented by 1 to align with the API’s page numbering.
  • The indexInPage indicates the index of the item within its respective page.

Handling Loading and Error States

While a page request is ongoing, the UI displays loading indicators. Similarly, error messages are shown if an API request fails. By managing these states effectively, we ensure a smooth user experience.

Enhancing Error Handling with Retry Option

To improve error handling, we can implement a custom MovieListTileError widget with a retry option. Here’s the widget class for achieving this:

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) {
    // Show error only for 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();
  }
}

This widget displays the error message along with a retry button. Clicking the retry button invalidates and refreshes only the page that failed loading, ensuring a seamless user experience.

Handling End of Data

To address the issue of overscrolling when reaching the end of the paginated data, we can optimize the itemCount property of the ListView.builder:

// Fetch the first page to retrieve the total number of results
final responseAsync = ref.watch(fetchMoviesProvider(1));
final totalResults = responseAsync.valueOrNull?.totalResults;
// Pass the itemCount explicitly to prevent unnecessary renders during overscroll
return ListView.builder(
  itemCount: totalResults,
  itemBuilder: (context, index) {
    ...
  },
);

This optimization ensures that Flutter only renders items within the pagination range, preventing unnecessary renders during overscrolling.

Refreshing the Paginated Data

Implementing a pull-to-refresh UI for refreshing paginated data is straightforward with a RefreshIndicator. Here’s how it can be done:

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 the ListView.builder with a RefreshIndicator
    return RefreshIndicator(
      onRefresh: () async {
        // Invalidate all previously fetched pages
        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(...),
    );
  }
}

Within the onRefresh callback, we invalidate all previous pages and fetch the first page again to trigger a refresh. This approach ensures that users can seamlessly refresh the paginated data with a pull-to-refresh gesture.

Join Our Whatsapp Group

Join Telegram group

By implementing these enhancements, we create a robust pagination system for production-grade Flutter apps.

Caching Paginated Data in Flutter with Riverpod

When working with paginated data in Flutter using Riverpod, it’s essential to consider caching strategies to optimize performance and minimize unnecessary API calls. While Riverpod inherently provides caching for active providers, we need to address scenarios where providers are disposed due to offscreen items in ListView.builder. To achieve efficient caching without consuming excessive memory, we can implement a timeout-based caching strategy using a Timer and CancelToken. Here’s how to do it:

@riverpod
Future<TMDBMoviesResponse> fetchMovies(FetchMoviesRef ref, int page) {
  final moviesRepo = ref.watch(moviesRepositoryProvider);
  // Initialize a CancelToken to cancel requests if needed
  final cancelToken = CancelToken();
  // Keep the provider alive for a certain duration
  final link = ref.keepAlive();
  // Declare a Timer to control the cache timeout
  Timer? timer;

  // Dispose resources when the provider is disposed
  ref.onDispose(() {
    cancelToken.cancel();
    timer?.cancel();
  });

  // Start the cache timeout when the last listener is removed
  ref.onCancel(() {
    timer = Timer(const Duration(seconds: 30), () {
      // Dispose the cached data on timeout
      link.close();
    });
  });

  // Cancel the timer if the provider is listened again after being paused
  ref.onResume(() {
    timer?.cancel();
  });

  // Pass the CancelToken to the API call to enable cancellation
  return moviesRepo.nowPlayingMovies(page: page, cancelToken: cancelToken);
}

In this implementation:

  • We use a CancelToken to cancel HTTP requests if they become unnecessary, such as when the user scrolls past a page being loaded.
  • The provider is kept alive for a specified duration using a Timer and CancelToken, ensuring that the data remains cached for a limited period.
  • When the provider is disposed or becomes inactive, we cancel ongoing requests and start a timer to dispose of the cached data after the specified timeout.
  • If the provider is listened again before the cache timeout, we cancel the timer to prevent premature disposal of cached data.

By employing this caching strategy, we strike a balance between memory management and data freshness, ensuring optimal performance and resource utilization in our Flutter app.

Frequently Asked Questions about Pagination in Mobile App Development

What is the role of pagination in mobile app development?

Pagination plays a crucial role in efficiently fetching data by dividing large datasets into manageable chunks or pages, improving user experience by reducing load times and data consumption.

What are the key considerations for implementing pagination in mobile apps?

Considerations include determining the loading mechanism, handling loading and error states, implementing data refresh functionality, integrating search capabilities, and optimizing pagination logic.

How can Riverpod be utilized for pagination in Flutter apps?

Riverpod can be leveraged to build an efficient pagination strategy by managing data providers, caching, and state management, ensuring a smooth user experience while fetching and displaying paginated data.

Is it better to use existing pagination solutions or roll your own with Riverpod?

The choice depends on the specific requirements of your app. While existing packages like infinite_scroll_pagination offer flexibility, integrating pagination with Riverpod can be advantageous for apps already built using Riverpod, allowing for a more cohesive architecture.

What are the main components of the Riverpod architecture for pagination?

The Riverpod-based architecture consists of three layers: the Data Layer, which includes the repository responsible for interacting with the API, the Domain Layer defining data models, and the Presentation Layer consisting of custom widget classes.

How can pagination logic be implemented with Riverpod and Flutter?

Pagination logic involves defining providers for fetching data, managing loading and error states, calculating page indices, handling data refreshing, and optimizing ListView builders for infinite scrolling.

Join Our Whatsapp Group

Join Telegram group

What are some optimization techniques for pagination in Flutter apps?

Optimization techniques include caching fetched data, debouncing search queries, optimizing ListView builders, implementing pull-to-refresh functionality, and efficiently managing memory usage.

What caching strategy can be employed for paginated data in Flutter apps using Riverpod?

A timeout-based caching strategy using a Timer and CancelToken can be implemented to cache data for a limited period, ensuring optimal performance and resource utilization while maintaining data freshness.

2 thoughts on “Flutter Pagination with Riverpod: The Ultimate Guide”

Leave a Reply

Unlocking Potential with Apple Vision Pro Labs Navigating 2023’s Top Mobile App Development Platforms Flutter 3.16: Revolutionizing App Development 6 Popular iOS App Development Languages in 2023 Introducing Workflow Apps: Your Flutter App Development Partner