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.
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.
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.
Pagination with Search
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:
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.
Github link?
https://github.com/bizz84/tmdb_movie_app_riverpod
Can you be more specific about the content of your article? After reading it, I still have some doubts. Hope you can help me.
Thank you for your sharing. I am worried that I lack creative ideas. It is your article that makes me full of hope. Thank you. But, I have a question, can you help me?
Thank you for your sharing. I am worried that I lack creative ideas. It is your article that makes me full of hope. Thank you. But, I have a question, can you help me? https://www.binance.com/sl/register?ref=OMM3XK51
Thanks for sharing. I read many of your blog posts, cool, your blog is very good.
Thank you for your sharing. I am worried that I lack creative ideas. It is your article that makes me full of hope. Thank you. But, I have a question, can you help me?