Skip to content

${redev}

Published 1/19/2024 · 7 min read

Tags: vue , pinia , pagination , components , typescript

In the API Resources article we set up a UserResource using the paginate method. Laravel returns paginated data in this format:

{
  "data": [
    { "id": 1, "name": "Luke Skywalker", "email": "luke@jedi.com" },
    { "id": 2, "name": "Ben Kenobi", "email": "ben@jedi.com" }
  ],
  "links": {
    "first": "http://example.com/users?page=1",
    "last": "http://example.com/users?page=5",
    "prev": null,
    "next": "http://example.com/users?page=2"
  },
  "meta": {
    "current_page": 1,
    "from": 1,
    "last_page": 5,
    "per_page": 15,
    "to": 15,
    "total": 75
  }
}

Let’s build a reusable pagination system with Vue 3 and Pinia.

Types

First, define the types for paginated responses. Create src/types/pagination.ts:

export interface PaginationLinks {
  first: string;
  last: string;
  prev: string | null;
  next: string | null;
}

export interface PaginationMeta {
  current_page: number;
  from: number;
  last_page: number;
  per_page: number;
  to: number;
  total: number;
}

export interface PaginatedResponse<T> {
  data: T[];
  links: PaginationLinks;
  meta: PaginationMeta;
}

User Service

Create src/services/users.ts:

import api from "./api";
import type { PaginatedResponse } from "@/types/pagination";

export interface User {
  id: number;
  name: string;
  email: string;
  email_verified_at: string | null;
  is_admin: boolean;
  avatar: string | null;
  created_at: string;
}

export const userService = {
  async getUsers(page = 1): Promise<PaginatedResponse<User>> {
    const response = await api.get(`/api/users?page=${page}`);
    return response.data;
  },

  async getUsersByUrl(url: string): Promise<PaginatedResponse<User>> {
    const response = await api.get(url);
    return response.data;
  },
};

Users Store with Pinia

Create src/stores/users.ts:

import { defineStore } from "pinia";
import { ref, computed } from "vue";
import { userService, type User } from "@/services/users";
import type { PaginationLinks, PaginationMeta } from "@/types/pagination";

export const useUsersStore = defineStore("users", () => {
  const users = ref<User[]>([]);
  const meta = ref<PaginationMeta | null>(null);
  const links = ref<PaginationLinks | null>(null);
  const isLoading = ref(false);
  const error = ref<string | null>(null);

  const hasUsers = computed(() => users.value.length > 0);
  const currentPage = computed(() => meta.value?.current_page ?? 1);
  const lastPage = computed(() => meta.value?.last_page ?? 1);
  const hasPrevPage = computed(() => links.value?.prev !== null);
  const hasNextPage = computed(() => links.value?.next !== null);

  async function fetchUsers(page = 1) {
    isLoading.value = true;
    error.value = null;

    try {
      const response = await userService.getUsers(page);
      users.value = response.data;
      meta.value = response.meta;
      links.value = response.links;
    } catch (e: any) {
      error.value = e.response?.data?.message || "Failed to fetch users";
    } finally {
      isLoading.value = false;
    }
  }

  async function goToPage(url: string) {
    isLoading.value = true;
    error.value = null;

    try {
      const response = await userService.getUsersByUrl(url);
      users.value = response.data;
      meta.value = response.meta;
      links.value = response.links;
    } catch (e: any) {
      error.value = e.response?.data?.message || "Failed to fetch users";
    } finally {
      isLoading.value = false;
    }
  }

  function goToFirst() {
    if (links.value?.first) {
      goToPage(links.value.first);
    }
  }

  function goToPrev() {
    if (links.value?.prev) {
      goToPage(links.value.prev);
    }
  }

  function goToNext() {
    if (links.value?.next) {
      goToPage(links.value.next);
    }
  }

  function goToLast() {
    if (links.value?.last) {
      goToPage(links.value.last);
    }
  }

  return {
    users,
    meta,
    links,
    isLoading,
    error,
    hasUsers,
    currentPage,
    lastPage,
    hasPrevPage,
    hasNextPage,
    fetchUsers,
    goToPage,
    goToFirst,
    goToPrev,
    goToNext,
    goToLast,
  };
});

Pagination Component

Create a reusable src/components/BasePagination.vue:

<script setup lang="ts">
import type { PaginationLinks, PaginationMeta } from "@/types/pagination";

interface Props {
  meta: PaginationMeta;
  links: PaginationLinks;
  isLoading?: boolean;
}

const props = withDefaults(defineProps<Props>(), {
  isLoading: false,
});

const emit = defineEmits<{
  (e: "first"): void;
  (e: "prev"): void;
  (e: "next"): void;
  (e: "last"): void;
}>();
</script>

<template>
  <nav
    v-if="meta.last_page > 1"
    class="flex items-center justify-between border-t border-gray-200 px-4 py-3 sm:px-6"
    aria-label="Pagination"
  >
    <div class="hidden sm:block">
      <p class="text-sm text-gray-700">
        Showing
        <span class="font-medium">{{ meta.from }}</span>
        to
        <span class="font-medium">{{ meta.to }}</span>
        of
        <span class="font-medium">{{ meta.total }}</span>
        results
      </p>
    </div>

    <div class="flex flex-1 justify-between sm:justify-end gap-2">
      <button
        type="button"
        :disabled="!links.prev || isLoading"
        @click="emit('first')"
        class="relative inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
      >
        First
      </button>

      <button
        type="button"
        :disabled="!links.prev || isLoading"
        @click="emit('prev')"
        class="relative inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
      >
        Previous
      </button>

      <span
        class="relative inline-flex items-center px-4 py-2 text-sm font-semibold text-gray-700"
      >
        {{ meta.current_page }} / {{ meta.last_page }}
      </span>

      <button
        type="button"
        :disabled="!links.next || isLoading"
        @click="emit('next')"
        class="relative inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
      >
        Next
      </button>

      <button
        type="button"
        :disabled="!links.next || isLoading"
        @click="emit('last')"
        class="relative inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
      >
        Last
      </button>
    </div>
  </nav>
</template>

Users View

Create src/views/UsersView.vue:

<script setup lang="ts">
import { onMounted, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useUsersStore } from "@/stores/users";
import BasePagination from "@/components/BasePagination.vue";

const route = useRoute();
const router = useRouter();
const usersStore = useUsersStore();

// Fetch users on mount
onMounted(() => {
  const page = parseInt(route.query.page as string) || 1;
  usersStore.fetchUsers(page);
});

// Update URL when page changes
watch(
  () => usersStore.currentPage,
  (page) => {
    router.replace({ query: { page: page.toString() } });
  }
);

function handleFirst() {
  usersStore.goToFirst();
}

function handlePrev() {
  usersStore.goToPrev();
}

function handleNext() {
  usersStore.goToNext();
}

function handleLast() {
  usersStore.goToLast();
}
</script>

<template>
  <div class="max-w-4xl mx-auto py-10">
    <h1 class="text-2xl font-bold mb-6">Users</h1>

    <!-- Loading State -->
    <div v-if="usersStore.isLoading" class="text-center py-10">
      <p class="text-gray-500">Loading users...</p>
    </div>

    <!-- Error State -->
    <div
      v-else-if="usersStore.error"
      class="bg-red-50 text-red-600 p-4 rounded"
    >
      {{ usersStore.error }}
    </div>

    <!-- Users List -->
    <div v-else>
      <div class="bg-white shadow rounded-lg overflow-hidden">
        <table class="min-w-full divide-y divide-gray-200">
          <thead class="bg-gray-50">
            <tr>
              <th
                class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase"
              >
                Name
              </th>
              <th
                class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase"
              >
                Email
              </th>
              <th
                class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase"
              >
                Status
              </th>
            </tr>
          </thead>
          <tbody class="bg-white divide-y divide-gray-200">
            <tr v-for="user in usersStore.users" :key="user.id">
              <td class="px-6 py-4 whitespace-nowrap">
                <div class="flex items-center">
                  <img
                    v-if="user.avatar"
                    :src="user.avatar"
                    :alt="user.name"
                    class="h-10 w-10 rounded-full"
                  />
                  <div
                    v-else
                    class="h-10 w-10 rounded-full bg-gray-200 flex items-center justify-center"
                  >
                    <span class="text-gray-500 text-sm">
                      {{ user.name.charAt(0).toUpperCase() }}
                    </span>
                  </div>
                  <div class="ml-4">
                    <div class="text-sm font-medium text-gray-900">
                      {{ user.name }}
                    </div>
                  </div>
                </div>
              </td>
              <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
                {{ user.email }}
              </td>
              <td class="px-6 py-4 whitespace-nowrap">
                <span
                  v-if="user.email_verified_at"
                  class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800"
                >
                  Verified
                </span>
                <span
                  v-else
                  class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-yellow-100 text-yellow-800"
                >
                  Pending
                </span>
              </td>
            </tr>
          </tbody>
        </table>

        <!-- Pagination -->
        <BasePagination
          v-if="usersStore.meta && usersStore.links"
          :meta="usersStore.meta"
          :links="usersStore.links"
          :is-loading="usersStore.isLoading"
          @first="handleFirst"
          @prev="handlePrev"
          @next="handleNext"
          @last="handleLast"
        />
      </div>
    </div>
  </div>
</template>

Generic Pagination Composable

For more flexibility, create a composable that works with any paginated data. Create src/composables/usePagination.ts:

import { ref, computed } from "vue";
import type {
  PaginationLinks,
  PaginationMeta,
  PaginatedResponse,
} from "@/types/pagination";

export function usePagination<T>(
  fetchFunction: (page: number) => Promise<PaginatedResponse<T>>
) {
  const items = ref<T[]>([]) as { value: T[] };
  const meta = ref<PaginationMeta | null>(null);
  const links = ref<PaginationLinks | null>(null);
  const isLoading = ref(false);
  const error = ref<string | null>(null);

  const currentPage = computed(() => meta.value?.current_page ?? 1);
  const lastPage = computed(() => meta.value?.last_page ?? 1);
  const hasPrev = computed(() => links.value?.prev !== null);
  const hasNext = computed(() => links.value?.next !== null);

  async function fetchPage(page = 1) {
    isLoading.value = true;
    error.value = null;

    try {
      const response = await fetchFunction(page);
      items.value = response.data;
      meta.value = response.meta;
      links.value = response.links;
    } catch (e: any) {
      error.value = e.response?.data?.message || "Failed to fetch data";
    } finally {
      isLoading.value = false;
    }
  }

  function goToFirst() {
    fetchPage(1);
  }

  function goToPrev() {
    if (meta.value && meta.value.current_page > 1) {
      fetchPage(meta.value.current_page - 1);
    }
  }

  function goToNext() {
    if (meta.value && meta.value.current_page < meta.value.last_page) {
      fetchPage(meta.value.current_page + 1);
    }
  }

  function goToLast() {
    if (meta.value) {
      fetchPage(meta.value.last_page);
    }
  }

  return {
    items,
    meta,
    links,
    isLoading,
    error,
    currentPage,
    lastPage,
    hasPrev,
    hasNext,
    fetchPage,
    goToFirst,
    goToPrev,
    goToNext,
    goToLast,
  };
}

Usage example:

<script setup lang="ts">
import { onMounted } from "vue";
import { usePagination } from "@/composables/usePagination";
import { userService, type User } from "@/services/users";

const {
  items: users,
  meta,
  links,
  isLoading,
  fetchPage,
  goToNext,
  goToPrev,
} = usePagination<User>(userService.getUsers);

onMounted(() => fetchPage(1));
</script>

Next up: Hosting and deploying your application.

Related Articles

  • Your First Component

    Learn the anatomy of a Svelte component file. Understand how script, markup, and styles work together in a single .svelte file.

  • Component Lifecycle

    Understand when Svelte components mount, update, and destroy. Master onMount, onDestroy, beforeUpdate, afterUpdate, and tick.

  • Event Handling

    Handle DOM events and create custom component events in Svelte. Learn event modifiers, dispatching, and forwarding patterns.