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!
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:
- Query for documents with a limit
- If paginating, then use the passed in document as the query cursor
- If the number of fetched documents is equal to our limit, then we define the last document as the query cursor.
- 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.