I recently built a pagination component using the composition API. In this post I’m sharing my solution for doing client side pagination. All the code is available in this CodeSandbox. Let me know in the comment section bellow if you want me to share my solution for server side pagination too. I hope that you will find this useful.

A screenshot of the final result

The composable pagination state

I identified the following state to be neccessary to track for a fully functioning pagination component:

  • Number of pages: The total number of available pages
  • Current page: The page that is currently being displayed
  • Rows per page: The number of rows to display on each page
  • Array to paginate: An array containing all rows you want to perform client side pagination on

This state is used in a composable function called useClientSidePagination. The number of rows per page, the array to paginate and the current page are parameters to the composable function. These are refs, allowing the composable function to react to changes to them. This can be useful if you want the user to change the number of rows per page dynamically.

Here is what the composable function looks like:

useClientSidePagination.ts

import { computed, Ref, ref } from "vue";

interface PaginationConfig<T> {
  rowsPerPage?: Ref<number>;
  arrayToPaginate: Ref<T[]>;
  currentPage: Ref<number>;
}

export function usePagination<T>(config: PaginationConfig<T>) {
  const rowsPerPage = config.rowsPerPage || ref(100);

  const paginatedArray = computed(() =>
    config.arrayToPaginate.value.slice(
      (config.currentPage.value - 1) * rowsPerPage.value,
      config.currentPage.value * rowsPerPage.value
    )
  );

  const numberOfPages = computed(() =>
    Math.ceil((config.arrayToPaginate.value.length || 0) / rowsPerPage.value)
  );

  return {
    paginatedArray,
    numberOfPages
  };
}

Two important computed properties are returned from the function: the paginated array, and the total number of pages available.

This reusable piece of functionality will be useful when we start gluing it with other components.

The pagination component

Just having a nice composable function for pagination isn’t enough. You need something that the user can see and interact with for it to be useful. This is what the Vue component below is for.

This component is basically just for presentation, making it highly reusable across your project. E.g, it doesn’t care if you are using client or server side pagination.

PaginationComponent.vue

<template>
  <div class="pagination-container" aria-label="row pagination">
    <ul v-if="numberOfPages >= 1" class="pagination">
      <li
        class="page-item"
        aria-label="go to previous page"
        @click="previous()"
        :class="{
          disabled: currentPage === 1,
        }"
      >
        <span class="page-link">&laquo;</span>
      </li>
      <li
        v-for="index in numberOfPages"
        :key="index"
        :aria-label="'go to page ' + index"
        class="page-item"
        @click="setCurrentPage(index)"
      >
        <div
          class="page-link"
          :class="{
            'active-page': currentPage === index,
          }"
        >
          {{ index }}
        </div>
      </li>
      <li
        class="page-item"
        :class="{
          disabled: currentPage === numberOfPages || !numberOfPages,
        }"
        aria-label="go to next page"
        @click="next()"
      >
        <div class="page-link">&raquo;</div>
      </li>
    </ul>
  </div>
</template>

<script lang="ts" setup>
import { toRefs } from "vue";

const props = defineProps({
  numberOfPages: {
    required: true,
  },
  modelValue: {
    required: true,
    type: Number,
  },
});

const { numberOfPages, modelValue: currentPage } = toRefs(props);

const emit = defineEmits(["update:modelValue"]);

const setCurrentPage = (number: Number) => {
  emit("update:modelValue", number);
};

const previous = () => {
  if (currentPage.value === 1) return;
  emit("update:modelValue", currentPage.value - 1);
};

const next = () => {
  if (currentPage.value >= numberOfPages.value) return;
  emit("update:modelValue", currentPage.value + 1);
};
</script>

<style scoped lang="scss">
.pagination-container {
  display: inline-block;
}

.pagination {
  background: white;
  margin: 0px;
  padding: 10px;
  display: flex;
  gap: 5px;
  align-items: center;
  border: none;
  box-sizing: border-box;
  overflow: hidden;
  word-wrap: break-word;
  align-content: center;
  border-radius: 14px;
}

.page-item {
  display: flex;
  cursor: pointer;
  margin-bottom: 0px;
  -webkit-touch-callout: none; /* iOS Safari */
  -webkit-user-select: none; /* Safari */
  -khtml-user-select: none; /* Konqueror HTML */
  -moz-user-select: none; /* Old versions of Firefox */
  -ms-user-select: none; /* Internet Explorer/Edge */
  user-select: none; /* Non-prefixed version, currently supported by Chrome, Edge, Opera and Firefox */
}

.page-link {
  color: #666B85;
  border-radius: 5px;
  padding: 10px 15px;
  font-size: 14px;
  font-weight: 800;
  &:hover {
    color: #333333;
    background-color: #e9e9e9;
    border: none;
  }
}

.active-page {
  background-color: #60d394 !important;
  color: white !important;
  &:hover {
    border: none;
  }
}

.disabled {
  .page-link {
    background-color: #f9fafb;
  }
  cursor: not-allowed;
}
</style>

Using our pagination composable function

As an example I will implement a composable that is working with a todo-API, where we want to paginate the array that is returned from the API.

useTodosApi.ts

import { ref, Ref } from "@vue/reactivity";
import axios from "axios";

import { usePagination } from "../components/pagination/useClientSidePagination";

const URL = "https://jsonplaceholder.typicode.com/todos/";

export interface Todo {
  id: number;
  title: string;
}

export function useTodosApi(
  currentPage: Ref<number>,
  rowsPerPage?: Ref<number>
) {
  const todos: Ref<Todo[]> = ref([]);

  const todosAreLoading = ref(false);

  const { paginatedArray, numberOfPages } = usePagination<Todo>({
    rowsPerPage,
    arrayToPaginate: todos,
    currentPage
  });

  const loadTodos = async () => {
    todosAreLoading.value = true;
    try {
      const result = await axios.get(URL);
      todos.value = result.data;
    } catch (err) {
      console.log(err);
    } finally {
      todosAreLoading.value = false;
    }
  };

  return {
    todos: paginatedArray,
    loadTodos,
    todosAreLoading,
    numberOfPages
  };
}

Gluing it all together

The following Vue component will use the pagination component, and the useTodosApi composition function to display the paginated list of todos in a table.

<template>
  <div class="container">
    <pagination-component
      class="pagination-component"
      v-model="currentPage"
      :numberOfPages="numberOfPages"
    />
    <table class="table-fill">
      <thead>
        <tr>
          <th>Id</th>
          <th class="text-left">Title</th>
        </tr>
      </thead>
      <tbody class="table-hover">
        <tr v-for="todo in todos" :key="todo.id">
          <td>{{ todo.id }}</td>
          <td class="text-left">{{ todo.title }}</td>
        </tr>
      </tbody>
    </table>
  </div>
</template>

<script lang="ts" setup>
import { onMounted, ref, watch } from "vue";
import PaginationComponent from "./components/pagination/PaginationComponent.vue";
import { useTodosApi } from "./composables/useTodosApi";

const currentPage = ref(1);
const rowsPerPage = ref(30);

const { todos, todosAreLoading, loadTodos, numberOfPages } = useTodosApi(
  currentPage,
  rowsPerPage
);

onMounted(async () => loadTodos());
</script>

<style lang="scss">
...
</style>

That’s it. Enjoy! If you have any questions, just ask in the comment section!