Flutter Pagination with Performing side effects in Riverpod

Kyaw Thet Wam
9 min readMay 11, 2024

--

Unlock the power of Riverpod for seamless infinite pagination in Flutter

In the realm of modern app development, seamless user experiences are paramount. Whether it’s a social media feed, an e-commerce product list, or a news aggregator, users expect smooth and uninterrupted scrolling through content.

Photo by cottonbro studio: https://www.pexels.com/photo/person-using-a-smartphone-5077039/

In this article, we dive into the world of infinite pagination, exploring how it can be efficiently implemented using Riverpod, a state management solution for Flutter applications. By leveraging the power and simplicity of Riverpod, developers can effortlessly integrate infinite pagination into their Flutter projects.

Unlocking Intermediate Complexity: Insights for Experienced Flutter Developers

Before we dive into the trick of infinite pagination with Riverpod, it’s essential to note that this article is designed for those with some experience in flutter app development. Intended for developers familiar with Flutter and state management concepts, we’ll be exploring complex topics related to dynamic data loading.

Evaluating Riverpod Pagination Strategies: Understanding Resource Variance and Project Alignment

Photo by Lukas: https://www.pexels.com/photo/laptop-computer-showing-c-application-574069/

While there are numerous articles and videos available on pagination with Riverpod, many of them offer valuable insights. One such notable example is Andrea’s latest article, which is based on Riverpod official pub example and is widely regarded as one of the best resources, particularly for applications such as social media platforms. However, despite the excellence of these resources, their approaches may not always align perfectly with specific project requirements. In this article, we will delve into the reasons why this is the case.

Navigating Riverpod Pagination Challenges: Strategies for Enhancing Data Fetching Efficiency and Overcoming Persistent Flutter Issues

In the Riverpod official pub example, everything is streamlined with less boilerplate code, providing an almost perfect solution. However, there is one Flutter issue that persists, which can be found at this link. Fortunately, there are two tricks that can mitigate this issue. The first involves using a keep-alive, which necessitates manually invalidating the family provider upon screen popping. The second workaround employs a combination of a Timer and ref.keepAlive, enabling the state to remain alive for a set duration before disposing and fetching data again. Additional explanations can be found in this discussion.

These approaches may be particularly suitable for scenarios such as continuously fetching data and displaying a Shimmer widget upon scrolling back up to obtain up-to-date data. If Flutter has addressed the issue, following the Riverpod official pub example would suffice. For this article’s purposes, however, we will exclusively use the list mutation approach.

Implementation of Pagination

First defines an abstract class named PaginatedDataItem. Here's what it does:

abstract class PagingDataItem {
int get id;
}
  • The purpose of this abstract class is to serve as a blueprint for data items used in a paging mechanism.

Create a user class that implements the PagingDataItem interface by providing an implementation for the id using Freezed.

@freezed
class User with _$User implements PagingDataItem {
const factory Person({
required int id,
required String name,
required String email,
required int address,
}) = _User;

factory User.fromJson(Map<String, Object?> json)
=> _$UserFromJson(json);
}

Next, create a repository class named UserRepository to fetch data from the backend API.


class UserRepository {
const UserRepository({required this.client, required this.apiKey});
final Dio dio;

Future<User> users(
{required int page, CancelToken? cancelToken}) async {

final response = await dio.get('${Enpoints}users?page=$page', cancelToken: cancelToken);
return User.fromJson(response.data);
}

Future<User> searchUsers(
{required Pagination pagination, CancelToken? cancelToken}) async {

final response = await dio.get('${Enpoints}users?page=${pagination.page}&query=${pagination.query}', cancelToken: cancelToken);
return User.fromJson(response.data);
}

Future<User> userById(
{required int id, CancelToken? cancelToken}) async {...}

Future<User> create(User user) async {...}

Future<User> update(User user) async {...}

Future<void> delete(User user) async {...}

}

@riverpod
UserRepository userRepository(UserRepositoryRef ref) => UserRepository(
dio: ref.watch(dioProvider));

Next, create PaginatedData model.

  • The purpose of this class is to represent paginated data in a structured format.
  • It encapsulates a list of items (items), the current page number (page), and a boolean flag indicating if there are more pages (hasMore).
  • By using generics, it allows for flexibility in the type of items contained in the paginated data, as long as they conform to the PagingDataItem interface.
@freezed
class PaginatedData<T extends PagingDataItem> with _$PaginatedData<T> {
const factory PaginatedData({
required List<T> items,
required int page,
required bool hasMore,
}) = _PaginatedData<T>;
}

Then create an abstract class PaginatedAsyncNotifierthat extends AutoDisposeAsyncNotifier and specifies a generic type T that extends PagingDataItem.

  • Method fetchNext is declared as abstract, meaning any concrete subclass must provide an implementation.
  • Method loadNext is implemented with default behavior but can be overridden by subclasses, asynchronously loads the next page of data annd checks the current state, handles error states, and loads the next page if hasMore is true.

abstract class PaginatedAsyncNotifier<T extends PagingDataItem>
extends AutoDisposeAsyncNotifier<PaginatedData<T>> {
Future<PaginatedData<T>> fetchNext(int page);

Future<void> loadNext() async {
final value = state.valueOrNull;

if (value == null) {
return;
}

if (state.unwrapPrevious().hasError) {
state = AsyncValue.data(value);
return;
}

if (value.hasMore) {
state = AsyncLoading<PaginatedData<T>>().copyWithPrevious(state);

state = await state.guardX(
() async {
final next = await fetchNext(value.page + 1);

return value.copyWith(
items: [...value.items, ...next.items],
page: value.page + 1,
hasMore: next.hasMore,
);
},
);
}
}
}

The guardX method is simply an extension of the Riverpod AsyncValue.


extension AsyncValueX<T> on AsyncValue<T> {
Future<AsyncValue<T>> guardX(Future<T> Function() future) async {
try {
return AsyncValue.data(await future());
} on DioException catch (e, stack) {
// here DioException is custom error handling implementing Exception Class.
final error = DioExceptions.fromDioError(e);
return AsyncValue<T>.error(error, stack).copyWithPrevious(this);
} catch (err, stack) {
return AsyncValue<T>.error("Something went wrong!", stack)
.copyWithPrevious(this);
}
}

Now that we’ve finished all the required components, all we need to do is create a provider for the user model.

typedef PaginatedUserState = PaginatedData<User>;

@riverpod
class PaginatedUserNotifier extends PaginatedAsyncNotifier<User> {
@override
Future<PaginatedUserState> build() async {
final userRepo = ref.watch(userRepositoryProvider);
final query = ref.watch(userSearchQueryNotifierProvider);

final cancelToken = CancelToken();

ref.onDispose(() {
cancelToken.cancel();
});

if (query.isEmpty) {
final res = await userRepo.users(
page: 1,
cancelToken: cancelToken,
);

return PaginatedUserState(
items: res.results,
page: 1,
hasMore: res.results.length >= pageSize,
);
} else {
// Debounce the request. See details https://riverpod.dev/docs/
await Future.delayed(const Duration(milliseconds: 500));
if (cancelToken.isCancelled) {
throw AbortedException();
}
// using search endpoint
final res = await userRepo.searchUsers(
pagination: (page: 1, query: query),
cancelToken: cancelToken,
);
return PaginatedUserState(
items: res.results,
page: 1,
hasMore: res.results.length >= pageSize,
);
}
}

@override
Future<PaginatedUserState> fetchNext(int page) async {
final userRepo = ref.watch(userRepositoryProvider);
final query = ref.watch(userSearchQueryNotifierProvider);

final cancelToken = CancelToken();
final currentPage = page + 1;

ref.onDispose(() {
cancelToken.cancel();
});

if (query.isEmpty) {
final res = await userRepo.users(
page: page,
cancelToken: cancelToken,
);

return PaginatedUserState(
items: res.results,
page: currentPage,
hasMore: res.results.length >= pageSize,
);

} else {

final res = await userRepo.searchUsers(
pagination: (page: page, query: query),
cancelToken: cancelToken,
);

return PaginatedUserState(
items: res.results,
page: currentPage,
hasMore: res.results.length >= pageSize,
);
}

}
}

UserSearchQueryNotifier:


@riverpod
class UserSearchQueryNotifier extends _$UserSearchQueryNotifier {
@override
String build() {
// by default, return an empty query
return '';
}

void setQuery(String query) {
state = query;
}
}

Then, all we have left to do is implement the User interface.


class UserScreen extends HookConsumerWidget {
const UserScreen({super.key});

@override
Widget build(BuildContext context, WidgetRef ref) {
final userNotifier = ref.read(paginatedUserNotifierProvider.notifier);

return Scaffold(
appBar: AppBar(
title: const Text('Users'),
),
body: Column(
children: [
const UserSearchBar(),
Expanded(
child: ref.watch(paginatedUserNotifierProvider).whenPlus(
data: (data, hasError) {
final endItem = data.hasMore && !hasError
? BottomWidget(
onScrollEnd: () => userNotifier.loadNext())
: data.hasMore && hasError
? ErrorWidget(
retryCallback: () => userNotifier.loadNext())
: null;
return RefreshIndicator(
onRefresh: () =>
ref.refresh(paginatedUserNotifierProvider.future),
child: ListView.builder(
key: const PageStorageKey('pageKey'),
itemCount:
data.items.length + (endItem != null ? 1 : 0),
itemBuilder: (context, index) {
if (endItem != null && index == data.items.length) {
return endItem;
}

return ListTile(
title: Text(data.items[index].name),
subtitle: Text(data.items[index].email),
);
},
),
);
},
loading: () => ListOfShimmerWidget(),
error: (e, st) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
onPressed: () =>
ref.invalidate(paginatedUserNotifierProvider),
icon: const Icon(Icons.refresh),
),
Text(e.toString()),
],
),
),
skipErrorOnHasValue: true,
),
),
],
),
);
}
}


class BottomWidget extends StatelessWidget {
final VoidCallback onScrollEnd;
const BottomWidget({
super.key,
required this.onScrollEnd,
});

@override
Widget build(BuildContext context) {
return VisibilityDetector(
key: key ?? const Key('Bottom'),
onVisibilityChanged: (info) {
if (info.visibleFraction > 0.1) {
onScrollEnd();
}
},
child: const ShimmerWidget() or CircularProgressIndicator(),
);
}
}



class ErrorWidget extends StatelessWidget {
const ErrorWidget({super.key, required this.retryCallback});

final VoidCallback retryCallback;

@override
Widget build(BuildContext context) {
return Column(
children: [
const Text("Something went wrong"),
TextButton(
onPressed: retryCallback,
child: const Text("Retry"),
),
],
);
}
}


class UserSearchbar extends ConsumerWidget {
const UserSearchbar({super.key});

@override
Widget build(BuildContext context, WidgetRef ref) {
return TextField(
onChanged: (text) =>
ref.read(userSearchQueryNotifierProvider.notifier).setQuery(text),
);
}
}

The whenPlus method is an extension of Riverpod AsyncValue, used to explore the hasError field when fetching the next set of data. If it’s true, it displays an error widget at the bottom with a retryCallback.

extension AsyncValueX<T> on AsyncValue<T> {
R whenPlus<R>({
bool skipLoadingOnReload = false,
bool skipLoadingOnRefresh = true,
bool skipError = false,
bool skipErrorOnHasValue = false,
required R Function(T data, bool hasError) data,
required R Function(Object error, StackTrace stackTrace) error,
required R Function() loading,
}) {
if (skipErrorOnHasValue) {
if (hasValue && hasError) {
return data(requireValue, true);
}
}

return when(
skipLoadingOnReload: skipLoadingOnReload,
skipLoadingOnRefresh: skipLoadingOnRefresh,
skipError: skipError,
data: (d) => data(d, hasError),
error: error,
loading: loading,
);
}
}

That’s all about pagination. Next, we’ll focus on implementing side effects, such as CRUD (Create, Read, Update, Delete) API operations. When implementing these, it’s common for an update request (typically a POST) to also update the local cache, ensuring that the UI reflects the new state.

Implementation of Performing side effects

Now that we have a user form Notifier, we can start adding methods to enable performing side effects (such as POST requests). This could be achieved by incorporating addUser, updateUser, and deleteUser methods into our notifier.


@riverpod
class AsyncUserFormNotifier extends _$AsyncUserFormNotifier {

// use fpdart package
// To leverage functional programming concepts and constructs within your Flutter application.
// Fpdart provides a set of functional programming utilities and data types,
// such as Option, Either, Task, and more,
// which can help you write more robust, predictable, and maintainable code.

@override
FutureOr<Option<Unit>> build() => const None();

Future<void> createUser(User val) async {
state = const AsyncValue.loading();

state = await state.guardX(() async {
final user =
await ref.watch(userRepositoryProvider).create(val);

return const Some(unit);
});
}

Future<void> updateUser(User val) async {
state = const AsyncValue.loading();

state = await state.guardX(() async {
final user =
await ref.watch(userRepositoryProvider).update(val);

return const Some(unit);
});
}

Future<void> deleteUser(User val) async {
state = const AsyncValue.loading();

state = await state.guardX(() async {
final user =
await ref.watch(userRepositoryProvider).delete(val);

return const Some(unit);
});
}
}

Then, we need to listen to the user form state and display a loading overlay, success snackbar, or error snackbar accordingly.

class UserScreen extends HookConsumerWidget {
const UserScreen({super.key});

@override
Widget build(BuildContext context, WidgetRef ref) {
final userNotifier = ref.read(paginatedUserNotifierProvider.notifier);

ref.listen(asyncUserFormNotifierProvider, (_,state)){
state.unwrapPrevious().maybeWhen(
orElse: () {},
loading: () => LoadingOverlay.show(),
data: (data) {
// replace your widget
LoadingOverlay.hide();
ShowSnackbarOnSuccess(context);
},
error: (e, stack) {
// replace your widget
LoadingOverlay.hide();
ShowSnackbarOnError(context);
},
);
}

return Scaffold(...)
}
}

Alright, we’re almost there. But wait, we need to update our user list when we finish updating user information. How do we handle that? It’s actually quite straightforward. We just need to add some mutation methods in our PaginatedAsyncNotifier.

abstract class PaginatedAsyncNotifier<T extends PagingDataItem>
extends AutoDisposeAsyncNotifier<PaginatedData<T>> {
Future<PaginatedData<T>> fetchNext(int page);

Future<void> loadNext() async {...}

// create
void createItem(T val) {
final previousValue = state.requireValue;

state = AsyncValue.data(previousValue.copyWith(
items: [...previousValue.items, val],
));
}

// update
void updateItem(T val) {
final previousValue = state.requireValue;

state = AsyncValue.data(previousValue.copyWith(
items: [
for (final v in previousValue.items)
if (v.id == val.id) val else v,
],
));
}

// delete
void deleteItem(T val) {
final previousValue = state.requireValue;

state = AsyncValue.data(previousValue.copyWith(
items: [
for (final v in previousValue.items)
if (v.id != val.id) v,
],
));
}

}

Next, we simply call these methods on our AsyncUserFormNotifier accordingly.

@riverpod
class AsyncUserFormNotifier extends _$AsyncUserFormNotifier {

// use fpdart package
// To leverage functional programming concepts and constructs within your Flutter application.
// Fpdart provides a set of functional programming utilities and data types,
// such as Option, Either, Task, and more,
// which can help you write more robust, predictable, and maintainable code.

@override
FutureOr<Option<Unit>> build() => const None();

Future<void> createUser(User val) async {
state = const AsyncValue.loading();

state = await state.guardX(() async {
final user =
await ref.watch(userRepositoryProvider).create(val);
// call create method to effect user list
ref.read(paginatedUserNotifierProvider.notifier).createItem(user);
return const Some(unit);
});
}

Future<void> updateUser(User val) async {
state = const AsyncValue.loading();

state = await state.guardX(() async {
final user =
await ref.watch(userRepositoryProvider).update(val);
// call update method to effect user list
ref.read(paginatedUserNotifierProvider.notifier).updateItem(user);
return const Some(unit);
});
}

Future<void> deleteUser(User val) async {
state = const AsyncValue.loading();

state = await state.guardX(() async {
final user =
await ref.watch(userRepositoryProvider).delete(val);
// call delete method to effect user list
ref.read(paginatedUserNotifierProvider.notifier).deleteItem(user);
return const Some(unit);
});
}
}

Congratulations! That’s all we’ve done.

Conclusion

In conclusion, implementing pagination with performing side effects in Riverpod provides a powerful and efficient solution for managing data in Flutter applications. By leveraging Riverpod state management capabilities and its asynchronous operations, developers can seamlessly fetch and display paginated data while handling side effects such as loading indicators and error states. With Riverpod’s flexibility, reactive caching and simplicity, developers have a reliable toolset to streamline the implementation of pagination with performing side effects, empowering them to create high-quality Flutter applications with ease.

References:

zenn.dev, riverpod.dev, Code With Andrea

--

--