flutter
/

BLoC Pagination: Infinite Scrolling & Load More Pattern

Last Sync: Today

On this page

12
0%
Advanced
5 min read
Remaining
5 minleft

Click any section to jump — progress syncs automatically

flutterAdvanced

BLoC Pagination: Infinite Scrolling & Load More Pattern

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

YAMLRead-only
1
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.

DARTRead-only
1
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];
}
DARTRead-only
1
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.

DARTRead-only
1
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.

DARTRead-only
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.

DARTRead-only
1
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.

DARTRead-only
1
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.

DARTRead-only
1
// In UI, you might also check if already loading
void _onScroll() {
  final state = context.read<PostBloc>().state;
  if (state is PostLoaded && !state.hasReachedMax && !_isLoadingMore) {
    context.read<PostBloc>().add(LoadMorePosts());
  }
}

// In bloc, you can also add a guard
if (currentState is PostLoaded && !currentState.hasReachedMax && !_isLoading) {
  // perform load
}

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.

DARTRead-only
1
// State
class PostLoaded extends PostState {
  final List<Post> posts;
  final bool hasReachedMax;
  final bool isLoadingMore;
  // ...
}

// In bloc, when loading more, emit a copy with isLoadingMore = true, then after fetch emit with isLoadingMore = false.

// In UI, you can show a small progress at the bottom when isLoadingMore is true.

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.

Test Your Knowledge

Q1
of 3

What is the purpose of the `hasReachedMax` flag in the state?

A
To indicate that the list is empty
B
To prevent further load more requests when no more data is available
C
To track the current page number
D
To show an error message
Q2
of 3

Which widget is typically used to detect when the user scrolls to the bottom?

A
ListView
B
ScrollController
C
NotificationListener
D
Both B and C
Q3
of 3

When refreshing the list, what should you do with the current page state?

A
Keep the current page and load more
B
Reset to page 1 and clear existing data
C
Append new data to existing list
D
Nothing, just keep scrolling

Frequently Asked Questions

How do I know when to trigger load more?

Typically, you listen to scroll position and trigger when the user reaches near the bottom (e.g., scrollController.position.pixels >= scrollController.position.maxScrollExtent - 200).

How do I reset pagination when the user refreshes?

Dispatch a RefreshPosts event that resets the state and fetches the first page again.

What if the API uses cursor‑based pagination (e.g., `nextToken`)?

Your state can include the next token instead of a page number. The flow remains similar – store the token, use it in the next request, and clear it when refreshing.

How can I show a loading indicator only for the next page, not the whole list?

Add an isLoadingMore boolean in the state. When you emit a loading more state, you keep the existing posts and show a small progress indicator at the bottom of the list.

How to handle pagination with `dio` and interceptors?

The same pattern works. You can inject Dio into the repository and make the requests. For large apps, you may use a base repository with common pagination logic.

Previous

bloc authentication

Next

bloc search filter

Related Content

Need help?

Explore our comprehensive docs or start a chat with our tech experts.