Back to blog

Firestore pagination with Vuexfire

Posted

Note: If you want to know the basics about state management with Vuexfire, checkout my previous article here.

Pagination has always been a tricky thing to code when you're writting your own back end so, but you're using something like Firebase, there are built-in methods to make your life a lot easier.

If you search on Google "firebase pagination" you'll probably end up in this page of the documentation where you can find how to easily use different methods like startAt startAfter and limit to create paginated queries. These work great if you're managing your store manually, I mean, triggering your own mutations to update the store, but if you are using Vuexfire, things get a little more complicated.

If you're familiar with Vuexfire or if you've read my previous article about it, you know that youc

In this article, I'll explain how to code a "Load more" or scroll pagination, which retrieves documents in small batches. You can use it as a reference to code other paginations, like one with "Next/Prev" buttons.

The best info I found about how to do this was this thread on StackOverflow so kudos to Tony O'Hagan for this 🤘.

Note: I'll take as a starting point the book app I created in my previous article, so feel free to clone the code from the repo and follow along 🤙

Pagination with Vuexfire

The first thing we need to do is to create a firestoreAction named getBooksBatch that runs a query against the 'books' collection and binds its result to an attribute named booksBatch in the state. This query uses limit() to actually limit the number of documents received, and startAfter, which receives as a parameter a reference to a document saved in my state (loadMoreLastBook) or null value if that's empty (like the first time). This query will be bound to a temporary attribute of the state named booksBatch.

// Part of: src/store/index.js

/**
 * Used for VuexFire Pagination. Returns the documents and its
 * Firestore references in _doc so they can be used in the
 * startAfter method to paginate
 * @param {*} doc - A firestore document reference
 */
const customSerializer = (doc) => {
  const data = doc.data()
  // adds _doc property to be used to paginate
  Object.defineProperty(data, '_doc', { value: doc })
  // adds id as enumerable property so we can easily access it
  Object.defineProperty(data, 'id', { value: doc.id, enumerable: true })
  return data
}

//...
actions:{
  getBooksBatch: firestoreAction((context, payload) => {
      return context
        .bindFirestoreRef(
          'booksBatch',
          booksCollection
            .orderBy('created', 'asc')
            .limit(payload.limit)
            .startAfter(context.state.loadMoreLastBook || null),
          // IMPORTANT: changes the default document serializer function
          // to get the document reference and id
          { serialize: customSerializer }
        )
        .then((books) => {
          console.log(`Got ${books.length} books`)
          if(books.length > 0){
            context.commit('MERGE_BOOKS_BATCH', { books })
            context.commit('SET_LOADMORE_LAST')
          }
          // set all loaded if we dont return as many as the limit
          if (books.length < payload.limit) context.commit('ALL_BOOKS_LOADED')
        })
    }),
}

As mentioned, the key aspects to paginate in Firebase are the limit() and startAfter() methods. The limit method is pretty simple as we just need to pass a number of items we want to retrieve. The startAfter method is more complex because it requires a document reference as a parameter (not a document id) and when we use Vuexfire, it saves in the state the document's data, not the document reference.

The good news is that the bindFirestoreRef method accepts a third argument with options (see the API docs), and one of them is the function used to serialize each document. As you can see in the example above, I'm using a function named customSerializer which, for each document, returns its data, the id and the document reference itself in a property named _doc .

Once the query retrieves a batch of documents, it will commit these mutations:

// Part of: src/store/index.js
// ...

mutations: {
    // adds Vuexfire built-in mutations
    ...vuexfireMutations,
    // own mutations
    MERGE_BOOKS_BATCH(state, payload) {
      console.log(`Adding ${payload.books.length} to the list`)
      state.allBooks = state.allBooks.concat(payload.books)
    },
    SET_LOADMORE_LAST(state) {
      console.log('Setting last...')
      state.loadMoreLastBook = state.allBooks[state.allBooks.length - 1]._doc
      state.booksBatch = []
    },
    ALL_BOOKS_LOADED(state) {
      state.moreBooksPending = false
    },
}

This mutations are pretty straight forward. First, the MERGE_BOOKS_BATCH will append a new batch of books to the allBooks property of the state. After that, the SET_LOADMORE_LAST will save in the state the document reference of the last book we have in the state. Lastly, if the number of items returned is less than the number of items we were trying to retrieve, the mutation ALL_BOOKS_LOADED will update a property moreBooksPending in the state to false.

To finish, we just need to create a few getters that we'll use in our view:

// Part of: src/store/index.js
// ...

getters: {
  allBooks: (state) => {
    return state.allBooks
  },
  moreBooks: (state) => {
    return state.moreBooksPending
  },
},

And that's all in the store. You can find the full code of the Vuex store in this file from the repo

Finally, we just need to create a view component in which we'll dispatch the getBooksBatch() action on the mounted hook and whenever the user clicks in the "Load more" button:

<template>
  <div>
    <div class="books-wrapper">
      <div class="book" v-for="book in allBooks" :key="book.id">
        <h2>{{ book.title }}</h2>
        <p class="subtitle">Written by {{ book.author }}</p>
        <p>{{ book.summary }}</p>

        <button class="btn-red" @click.once="deleteBook(book.id)">
          Delete book
        </button>
        <p class="subtitle"><strong>ID</strong> {{ book.id }}</p>
        <p class="subtitle">
          <strong>Created</strong> {{ book.created.toDate() }}
        </p>
      </div>
    </div>
    <button
      class="btn-blue mt"
      v-if="moreBooks"
      @submit.prevent
      @click="loadMore"
    >
      Load more
    </button>
    <p v-else class="mt">⚠️ There are no more books to load 📚</p>
  </div>
</template>

<script>
import { mapGetters } from 'vuex'

export default {
  name: 'App',
  data() {
    return {
      title: '',
      author: '',
      summary: '',
    }
  },
  mounted() {
    this.$store.commit('RESET_ALL')
    this.$store.dispatch('getBooksBatch', { limit: 3 })
  },
  computed: {
    ...mapGetters(['allBooks', 'moreBooks']),
  },
  methods: {
    loadMore() {
      this.$store.dispatch('getBooksBatch', { limit: 3 })
    },
  },
}
</script>

Conclusion

This is a basic solution that works but is not perfect. If one user adds items to the Firestore collection while another is "paginating", the later might not get the newest items until it refreshes and starts paginating again.

If you need somethig different, there are a few suggestions on how to create a pagination with "Forward/Backward" buttons in this thread in StackOverflow, but all of them have some pros and cons.

Hope you find this useful and remember that you can find the code of this article in this repo in GitHub.

Happy coding!

Included in categories

coding javascript firebase
Related articles

If you enjoyed this article consider sharing it on social media or buying me a coffee ✌️

Buy me a coffeeBuy me a coffee

Oh! and don't forget to follow me on Twitter where I share tons of dev tips 🤙