Build a Reusable Pagination Component with Vue.js

Filipa Lacerda

Paginating resources in web applications can be very helpful not only performance-wise but also from a user experience perspective.

In this article you’ll learn how to create a dynamic and reusable Vue.js pagination component.

Base Structure

A pagination component should allow the user to go to the first and last pages, move forward and backwards and change directly to a page in a close range.

Most applications make an API request every time the user changes the page. We need to make sure our component allows to do so, but we don’t want to make the request within the component. This way we’ll make sure the component is reusable throughout the whole application and the request are all made in the actions/service layer. We can accomplish this by triggering an event with the number of the page the user clicked.

There are several possible ways to implement pagination on an API endpoint. For this example let’s assume our API informs us about the number of results per page, the total number of pages and the current page. These will be our dynamic props.

If, instead, the API only informs about the total number of records, we can calculate the total number of pages by dividing the number of results with the number of results per page: totalResults / resultsPerPage.

We want to render a button to go to the first page, the previous one, range number of pages, next page and last one:

[first] [next] [1] [2] [3] [previous] [last]

Although we want to render a range of pages, we do not want to render all available pages. Let’s also allow to configure the maximum number of visible buttons as a prop in our component.

Now that we know what we want our component to do and which data we’ll need, we can set the HTML structure and the needed props.

pagination.vue

<script>
export default {
  props: {
    maxVisibleButtons: {
      type: Number,
      required: false,
      default: 3
    },
    totalPages: {
      type: Number,
      required: true
    },
    total: {
      type: Number,
      required: true
    },
    currentPage: {
      type: Number,
      required: true
    }
  }
};
</script>
<template>
  <ul>
    <li>
      <button
        type="button"
      >
        First
      </button>
    </li>

    <li>
      <button
        type="button"
      >
        Previous
      </button>
    </li>

    <!-- Range of pages -->

    <li>
      <button
        type="button"
      >
        Next
      </button>
    </li>

    <li>
      <button
        type="button"
      >
        Last
      </button>
    </li>
  </ul>
</template>

In order to get the range of visible pages we’ll use a for loop.

We need to make sure the number is never bigger than the prop that sets the maximum number of visible buttons and also not bigger than the number of available pages.

The start number of our cycle depends on the current page:

  1. When the current page is the first one, let’s show the user the current one and the next ones.
  2. When the current page is the last one, let’s show the last page and the previous ones.
  3. For anything in between let’s show the previous page and the next one(s).

The end number of our cycle also needs some calculations. We need to get the smallest number between the total number of pages and the position where we want to stop. To calculate the position where where we want to stop, we need the sum of the position where we want to start plus the maximum number of visible buttons. Because we always want to show one button to the left hand of the current page, we need to subtract 1 from this number.

Let’s use a computed property that returns an array of objects with the range of visible pages. Each object will have a prop for the page number and another that will tell us whether the button should be disabled or not. After all we don’t want the user to click for the page he’s already on.

In order to render this array of pages we’ll use the v-for directive. For more complex data structures, it’s recommended to provide a key with each v-for. Vue uses the key value to find which element needs to be updated, when this value is not provided, Vue uses a “in-place patch” strategy. Although the data we are using is simple enough, let’s provide the key value - if you use eslint-vue-plugin with the essential rules, you will always need to provide the key value.

pagination.vue

<script>
export default {
  ...
  computed: {
    startPage() {
      // When on the first page
      if (this.currentPage === 1) {
        return 1;
      }
      // When on the last page
      if (this.currentPage === this.totalPages) {
        return this.totalPages - this.maxVisibleButtons;
      }
      // When in between
      return this.currentPage - 1;
    },
    pages() {
      const range = [];

      for (let i = this.startPage;
        i <= Math.min(this.startPage + this.maxVisibleButtons - 1, this.totalPages);
        i+= 1 ) {
        range.push({
          name: i,
          isDisabled: i === this.currentPage
        });
      }

      return range;
    },
  }
};
</script>
<template>
  <ul>
    ...
    <li
      v-for="page in pages"
      :key="page.name"
    >
      <button
        type="button"
        :disabled="page.isDisabled"
      >
        {{ page.name }}
      </button>
    </li>
    ...
  </ul>
</template>

Event Listeners

Now we need to inform the parent component when the user clicks in a button and which button the user has clicked.

We need to add an event listener to each of our buttons. The v-on directive allows to listen for DOM events. In this example I’ll use the v-on shorthand to listen for the click event.

In order to inform the parent, we’ll use the $emit method to emit an event with the page clicked.

Let’s also make sure the pagination buttons are only active if the page is available. In order to do so we’ll make use of v-bind to bind the value of the disabled attribute with the current page. We’ll also use the : shorthand for v-bind.

In order to keep our template cleaner, we’ll use the computed properties to check if the button should be disabled. Using computed properties will also cache values, which means that as long as currentPage won’t change, several access to the same computed property will return the previously computed result without having to run the function again.

pagination.vue

<script>
export default {
  ...
  computed: {
    isInFirstPage() {
      return this.currentPage === 1;
    },
    isInLastPage() {
      return this.currentPage === this.totalPages;
    },
  },
  methods: {
    onClickFirstPage() {
      this.$emit('pagechanged', 1);
    },
    onClickPreviousPage() {
      this.$emit('pagechanged', this.currentPage - 1);
    },
    onClickPage(page) {
      this.$emit('pagechanged', page);
    },
    onClickNextPage() {
      this.$emit('pagechanged', this.currentPage + 1);
    },
    onClickLastPage() {
      this.$emit('pagechanged', this.totalPages);
    }
  }
}
</script>
<template>
  <ul>
    <li>
      <button
        type="button"
        @click="onClickFirstPage"
        :disabled="isInFirstPage"
      >
        First
      </button>
    </li>

    <li>
      <button
        type="button"
        @click="onClickPreviousPage"
        :disabled="isInFirstPage"
      >
        Previous
      </button>
    </li>

    <li v-for="page in pages">
      <button
        type="button"
        @click="onClickPage(page.name)"
        :disabled="page.isDisabled"
      >
        {{ page.name }}
      </button>
    </li>

    <li>
      <button
        type="button"
        @click="onClickNextPage"
        :disabled="isInLastPage"
      >
        Next
      </button>
    </li>

    <li>
      <button
        type="button"
        @click="onClickLastPage"
        :disabled="isInLastPage"
      >
        Last
      </button>
    </li>
  </ul>
</template>

Styling

Now that our component checks all functionalities we initially wanted, we need to add some CSS to make it look more like a pagination component and less like a list.

We also want our users to be able to clearly identify which page they are on. Let’s change the color of the button representing the current page.

In order to so we can bind an HTML class to our active page button using the object syntax. When using the object syntax to bind class names, Vue will automatically toggle the class when the value changes.

Although each block inside a v-for has access to the parent scope properties, we’ll use a method to check if the page is active in order to keep our template cleaner:

pagination.vue

<script>
export default {
  ...
  methods: {
    isPageActive(page) {
      return this.currentPage === page;
    }
  }
}
</script>
<template>
  <ul class="pagination">
    <li class="pagination-item">
      <button
        type="button"
        @click="onClickFirstPage"
        :disabled="isInFirstPage"
      >
        First
      </button>
    </li>

    <li class="pagination-item">
      <button
        type="button"
        @click="onClickPreviousPage"
        :disabled="isInFirstPage"
      >
        Previous
      </button>
    </li>

    <li
      v-for="page in pages"
      class="pagination-item"
    >
      <button
        type="button"
        @click="onClickPage(page.name)"
        :disabled="page.isDisabled"
        :class="{ active: isPageActive(page.name) }"
      >
        {{ page.name }}
      </button>
    </li>

    <li class="pagination-item">
      <button
        type="button"
        @click="onClickNextPage"
        :disabled="isInLastPage"
      >
        Next
      </button>
    </li>

    <li class="pagination-item">
      <button
        type="button"
        @click="onClickLastPage"
        :disabled="isInLastPage"
      >
        Last
      </button>
    </li>
  </ul>
</template>

<style>
.pagination {
  list-style-type: none;
}

.pagination-item {
  display: inline-block;
}

.active {
  background-color: #4AAE9B;
  color: #ffffff;
}
</style>

🎉 Our pagination component is ready! You can see the full source code for this component in this Codepen.

  Tweet It

🕵 Search Results

🔎 Searching...

Sponsored by #native_company# — Learn More
#native_title# #native_desc#
#native_cta#