Launch Club

How to Paginate Documents in a Firestore Collection with Flutter

Flutter Firebase

Published Nov 20, 2023

Marcus Ng

Marcus Ng

Imagine we’re building an app with Flutter and Firebase Firestore that displays all of the post documents in a collection called posts.

Each Post class has five values (String id, String title, String content, String authorId, and DateTime createdAt), and toJson()/fromJson() methods.

A common mistake that developers make when they first start using Firestore is that they fetch all of the documents in this collection and display it to the user.

final _firebaseFirestore = FirebaseFirestore.instance;

Future<List<Post>> readPosts() async {
  final snapshot = await _firebaseFirestore
      .collection('posts')
      .orderBy('publishedAt', descending: true)
      .get();
  return snapshot.docs.map(Post.fromJson).toList();
}

This means that if the collection has thousands of documents, a single user is potentially fetching thousands of documents when readPosts is called.

Firebase has a free daily quota of 50k document reads, which would be exceeded very quickly even if with less than 100 users using our app!

Firestore Pricing

Not to mention, we’re also introducing performance issues into your app as it takes more time to retrieve, transfer, and process all of the Firestore documents at once.

The solution is to implement pagination!

Why use pagination?

Pagination allows us to load batches of documents from Firestore.

This is more efficient because users do not need to load thousands of documents. They can only see a few posts on their device’s screen at a time, so it makes more sense to load less than 10 posts at a time.

When a user gets closer to the bottom or reaches the bottom of the scroll view, we can fetch and display the next batch of posts.

Pagination with cursors

One way to paginate in Firestore is to use a “query cursor.” A cursor in Firestore is a reference to a specific document in a collection that marks a position/point in our dataset.

We can break our function down into 4 steps:

  1. Query for documents with a limit
  2. If paginating, then use the passed in document as the query cursor
  3. If the number of fetched documents is equal to our limit, then we define the last document as the query cursor.
  4. Return the fetched documents and query cursor.
Future<(List<Post>, DocumentSnapshot?)> readPosts({
  int limit = 5,
  DocumentSnapshot<Object?>? startAfterDoc,
}) async {
  try {
    // 1) Query for documents with a limit.
    final query = _firebaseFirestore
        .collection(postsCollection)
        .orderBy('publishedAt', descending: true)
        .limit(limit);

    // 2) If paginating, then use the passed
    //    in document as the query cursor.
    final querySnapshot = startAfterDoc != null
        ? await query.startAfterDocument(startAfterDoc).get()
        : await query.get();

    // 3) If the number of fetched documents is equal to our limit,
    //    then we define the last document as the query cursor.
    final finalDoc = querySnapshot.docs.length == limit
				? querySnapshot.docs.last
				: null;

    // 4) Return the fetched documents and query cursor.
    return (
      querySnapshot.docs.map(Post.fromDoc).toList(),
      finalDoc,
    );
  } catch (_) {
    throw ReadPostsException();
  }
}

When using readPosts in our app, we need to keep track of the existing list of fetched posts and the query cursor.

In this example, we’ll use flutter_bloc for state management and freezed for code generation.

Check out this post on how to use freezed code generation with bloc

The PostsState stores the existing list of posts and the query cursor document.

// posts_state.dart

part of 'post_cubit.dart';

@freezed
class PostsState with _$PostsState {
  const factory PostsState.initial({
    @Default(<Post>[]) List<Post> posts,
    DocumentSnapshot? lastDoc,
  }) = _PostsInitial;
  const factory PostsState.loading({
    required List<Post> posts,
    DocumentSnapshot? lastDoc,
  }) = _PostsLoading;
  const factory PostsState.paginating({
    required List<Post> posts,
    DocumentSnapshot? lastDoc,
  }) = _PostsPaginating;
  const factory PostsState.loaded({
    required List<Post> posts,
    DocumentSnapshot? lastDoc,
  }) = _PostsLoaded;
  const factory PostsState.error({
    required List<Post> posts,
    DocumentSnapshot? lastDoc,
    required Exception exception,
  }) = _PostsError;
}

The PostsCubit handles fetching the logic for fetching and paginating posts. I’ve put our readPosts method into a class called PostRepository.

// posts_cubit.dart

import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';

part 'blog_cubit.freezed.dart';
part 'blog_state.dart';

class PostsCubit extends Cubit<PostsState> {
  PostsCubit({
    required PostRepository postRepository,
  })  : _postRepository = postRepository,
        super(const _PostsInitial());

  final PostRepository _postRepository;

  Future<void> readPosts() async {
    try {
      // Not paginating.
      emit(const _PostsLoading(posts: []));
      final posts = await _postRepository.readPosts();
      emit(
        _PostsLoaded(
          posts: posts.item1,
          lastDoc: posts.item2,
        ),
      );
    } on Exception catch (e) {
      emit(_PostsError(posts: [], exception: e));
    }
  }

  Future<void> paginatePosts() async {
    try {
      if (state.lastDoc != null && state is! _PostsPaginating) {
        // Paginating.
        emit(
          _PostsPaginating(
            posts: state.posts,
            lastDoc: state.lastDoc,
          ),
        );
        final posts = await _postRepository.readPosts(
          startAfterDoc: state.lastDoc,
        );
        emit(
          _PostsLoaded(
            posts: state.posts + posts.item1,
            lastDoc: posts.item2,
          ),
        );
      }
    } on Exception catch (e) {
      emit(_PostsError(posts: [], exception: e));
    }
  }
}

When creating the UI, we can add a ScrollController to our ListView or GridView and attach a listener to paginate based on the current scroll position.

Note that PostsState.paginating is important because that lets us make sure we do not fetch the same batch of posts multiple times. We are also able to render UI letting the user know when we are fetching the next batch of posts.

We can implement this manually or use a package like infinite_scroll_pagination.

Firebase Firestore User Interface Package

Now, if paginating with cursors isn’t your cup of tea, you can also use the firebase_ui_firestore package.

The FirestoreListView widget supports loading and error UI, infinite scrolling, and page size. Typed responses are supported as well if we use Firestore’s withConverter (docs).

final postsQuery = FirebaseFirestore.instance.collection('posts')
	.orderBy('publishedAt', descending: true)
  .withConverter<Post>(
     fromFirestore: (snapshot, _) => Post.fromJson(snapshot.data()!),
     toFirestore: (post, _) => post.toJson(),
   );

FirestoreListView<Post>(
  query: postsQuery,
  loadingBuilder: (context) => CustomLoadingIndicator(),
  errorBuilder: (context, error, stackTrace) => CustomError(error, stackTrace),
  itemBuilder: (context, snapshot) {
    // `post` has a type of `Post`.
    final post = snapshot.data();
    return Text(post.title);
  },
);

Another benefit is that the FirestoreListView always shows the most up-to-date document.

That means if a field on the post document gets updated, the individual item in the FirestoreListView changes to reflect the update.

This could be useful if we always have to show the most recent title, or any post related data, to our users.

If we use the query cursor pagination method, the post documents will not update when a document changes.

We would have to refetch them or add a listener to the collection that updates the local documents accordingly.

Flutter and Dart

made simple.

Everything you need to build production ready apps.

No spam. Just updates. Unsubscribe whenever.