Pagination is essential for apps that display large datasets. Implementing it correctly with BLoC ensures a smooth user experience, efficient network usage, and maintainable code. This guide covers how to build an infinite scrolling list with load‑more functionality using BLoC, including handling page states, preventing duplicate requests, and refreshing data.
Why Use BLoC for Pagination?
- Reactive state – UI automatically updates when new data arrives.
- Separation of concerns – Pagination logic stays in the bloc, not in the UI.
- Testability – Pagination flow can be unit‑tested with
bloc_test. - Prevent duplicate requests – Bloc can track loading state to avoid multiple calls.
- Error recovery – Retry and refresh logic encapsulated.
Setup: Dependencies
dependencies:
flutter:
sdk: flutter
flutter_bloc: ^8.1.5
equatable: ^2.0.5
http: ^1.2.0 # or dio
get_it: ^7.6.7 # optional
Data Model & Repository
We'll use JSONPlaceholder as an example. The repository will return a list of posts and accept a page number and page size.
import 'package:equatable/equatable.dart';
class Post extends Equatable {
final int id;
final int userId;
final String title;
final String body;
const Post({required this.id, required this.userId, required this.title, required this.body});
factory Post.fromJson(Map<String, dynamic> json) => Post(
id: json['id'],
userId: json['userId'],
title: json['title'],
body: json['body'],
);
@override
List<Object?> get props => [id, userId, title, body];
}
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../models/post.dart';
abstract class PostRepository {
Future<List<Post>> getPosts({int page = 1, int limit = 10});
}
class PostRepositoryImpl implements PostRepository {
final http.Client client;
PostRepositoryImpl(this.client);
@override
Future<List<Post>> getPosts({int page = 1, int limit = 10}) async {
final response = await client.get(
Uri.parse('https://jsonplaceholder.typicode.com/posts?_page=$page&_limit=$limit'),
);
if (response.statusCode == 200) {
final List<dynamic> jsonList = json.decode(response.body);
return jsonList.map((json) => Post.fromJson(json)).toList();
} else {
throw Exception('Failed to load posts');
}
}
}
States
Define states that represent the different phases of pagination: initial, loading, loaded (with metadata), error, and optionally a state when we've reached the end.
import 'package:equatable/equatable.dart';
import '../../models/post.dart';
abstract class PostState extends Equatable {
const PostState();
@override
List<Object?> get props => [];
}
class PostInitial extends PostState {}
class PostLoading extends PostState {}
class PostLoaded extends PostState {
final List<Post> posts;
final bool hasReachedMax;
final int currentPage;
const PostLoaded({
required this.posts,
this.hasReachedMax = false,
this.currentPage = 1,
});
PostLoaded copyWith({
List<Post>? posts,
bool? hasReachedMax,
int? currentPage,
}) {
return PostLoaded(
posts: posts ?? this.posts,
hasReachedMax: hasReachedMax ?? this.hasReachedMax,
currentPage: currentPage ?? this.currentPage,
);
}
@override
List<Object?> get props => [posts, hasReachedMax, currentPage];
}
class PostError extends PostState {
final String message;
const PostError(this.message);
@override
List<Object?> get props => [message];
}
Events
We need events to trigger the initial load and subsequent loads (load more). Also a refresh event to reload from page 1.
import 'package:equatable/equatable.dart';
abstract class PostEvent extends Equatable {
const PostEvent();
@override
List<Object?> get props => [];
}
class FetchPosts extends PostEvent {
final bool isRefresh;
const FetchPosts({this.isRefresh = false});
}
class LoadMorePosts extends PostEvent {}
class RefreshPosts extends PostEvent {}
Bloc Implementation
The bloc handles the logic: initial load, load more (increment page), and refresh (reset state and reload). We also need to prevent duplicate requests while loading.
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../repositories/post_repository.dart';
import 'post_event.dart';
import 'post_state.dart';
class PostBloc extends Bloc<PostEvent, PostState> {
final PostRepository repository;
PostBloc(this.repository) : super(PostInitial()) {
on<FetchPosts>(_onFetchPosts);
on<LoadMorePosts>(_onLoadMorePosts);
on<RefreshPosts>(_onRefreshPosts);
}
Future<void> _onFetchPosts(FetchPosts event, Emitter<PostState> emit) async {
emit(PostLoading());
try {
final posts = await repository.getPosts(page: 1, limit: 10);
emit(PostLoaded(posts: posts, currentPage: 1, hasReachedMax: posts.length < 10));
} catch (e) {
emit(PostError(e.toString()));
}
}
Future<void> _onLoadMorePosts(LoadMorePosts event, Emitter<PostState> emit) async {
final currentState = state;
if (currentState is PostLoaded && !currentState.hasReachedMax) {
final nextPage = currentState.currentPage + 1;
try {
final newPosts = await repository.getPosts(page: nextPage, limit: 10);
if (newPosts.isEmpty) {
emit(currentState.copyWith(hasReachedMax: true));
} else {
emit(currentState.copyWith(
posts: List.of(currentState.posts)..addAll(newPosts),
currentPage: nextPage,
hasReachedMax: newPosts.length < 10,
));
}
} catch (e) {
emit(PostError(e.toString()));
}
}
}
Future<void> _onRefreshPosts(RefreshPosts event, Emitter<PostState> emit) async {
emit(PostLoading());
try {
final posts = await repository.getPosts(page: 1, limit: 10);
emit(PostLoaded(posts: posts, currentPage: 1, hasReachedMax: posts.length < 10));
} catch (e) {
emit(PostError(e.toString()));
}
}
}
UI: Infinite Scrolling
In the UI, we listen to the state and use a ScrollController to detect when the user reaches the bottom. When that happens, we dispatch a LoadMorePosts event. We also need to handle the loading indicator and error retry.
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../bloc/post_bloc.dart';
import '../bloc/post_event.dart';
import '../bloc/post_state.dart';
class PostPage extends StatefulWidget {
@override
_PostPageState createState() => _PostPageState();
}
class _PostPageState extends State<PostPage> {
final ScrollController _scrollController = ScrollController();
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) {
context.read<PostBloc>().add(LoadMorePosts());
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Posts'),
actions: [
IconButton(
icon: Icon(Icons.refresh),
onPressed: () => context.read<PostBloc>().add(RefreshPosts()),
),
],
),
body: BlocConsumer<PostBloc, PostState>(
listener: (context, state) {
if (state is PostError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.message)),
);
}
},
builder: (context, state) {
if (state is PostLoading) {
return Center(child: CircularProgressIndicator());
}
if (state is PostLoaded) {
return ListView.builder(
controller: _scrollController,
itemCount: state.hasReachedMax ? state.posts.length : state.posts.length + 1,
itemBuilder: (context, index) {
if (index >= state.posts.length) {
return Center(child: Padding(
padding: const EdgeInsets.all(8.0),
child: CircularProgressIndicator(),
));
}
final post = state.posts[index];
return ListTile(
title: Text(post.title),
subtitle: Text(post.body),
);
},
);
}
return Center(child: Text('Press refresh to load'));
},
),
);
}
}
Preventing Duplicate Requests
The bloc already checks hasReachedMax and only triggers a load if not at max. However, you should also consider adding a flag to prevent multiple simultaneous load‑more requests while one is already in progress. You can achieve this by checking the current state: only load more if the state is PostLoaded and not loading.
void _onScroll() {
final state = context.read<PostBloc>().state;
if (state is PostLoaded && !state.hasReachedMax && !_isLoadingMore) {
context.read<PostBloc>().add(LoadMorePosts());
}
}
if (currentState is PostLoaded && !currentState.hasReachedMax && !_isLoading) {
}
Alternative: Using a Pagination State with Loading Flag
Sometimes you want to show a loading indicator only for the next page, not the whole list. You can add a isLoadingMore flag in the state and handle the UI accordingly.
class PostLoaded extends PostState {
final List<Post> posts;
final bool hasReachedMax;
final bool isLoadingMore;
}
Best Practices
- Use
Equatable – Prevents unnecessary rebuilds. - Track
hasReachedMax – Prevents unnecessary requests. - Provide a refresh action – Allow users to reload data manually.
- Handle errors gracefully – Show an error message and allow retry.
- Avoid loading more while already loading – Use state flags to prevent duplicate requests.
- Dispose of scroll controllers – To avoid memory leaks.
- Use
BlocListener for side effects – Keep UI building logic separate. - Consider using
debounce for scroll events – For performance, but usually not needed.
Common Mistakes
- ❌ Not checking
hasReachedMax – Endless requests.
✅ Always check before loading more. - ❌ Calling
loadMore on every scroll event – Can cause multiple requests.
✅ Add a condition to avoid duplicate calls while loading. - ❌ Mutating the state list directly – UI may not update.
✅ Create a new list with
... or List.of(). - ❌ Not handling empty pages – If API returns empty, mark
hasReachedMax. - ❌ Forgetting to reset page when refreshing – Use
refresh event that resets to page 1.
Conclusion
Pagination with BLoC gives you a clean, testable way to handle infinite scrolling. By separating state and events, you can easily manage page numbers, loading indicators, and error states. The patterns shown here can be adapted to any API with pagination, whether using http or dio. Remember to always guard against duplicate requests and handle the end of the list gracefully.