Build A Vue.js Pagination Composable For Efficient Data Handling

by James Vasile 65 views

Introduction

Hey guys! Today, we're diving deep into building a pagination composable in Vue.js. Efficiently handling large datasets is a common challenge in modern web applications, and pagination is a crucial technique to address this. This article will walk you through the process of creating a reusable composable that simplifies data pagination in your Vue.js projects. We'll explore the code, discuss the architecture, and highlight the key considerations for building a robust pagination solution. Whether you're dealing with a massive list of users, products, or any other type of data, this guide will provide you with the tools and knowledge to implement pagination effectively.

The goal here is to create a flexible and maintainable composable that can be easily integrated into various components. By encapsulating the pagination logic into a composable, we can avoid code duplication and ensure consistency across our application. We'll cover everything from setting up the initial state to handling edge cases and error scenarios. So, let's get started and build a pagination composable that will make your Vue.js applications more performant and user-friendly!

Why Pagination Matters in Vue.js Applications

In the realm of Vue.js applications, pagination stands as a cornerstone for efficient data handling, especially when dealing with substantial datasets. Imagine an e-commerce platform showcasing thousands of products or a social media feed teeming with countless posts. Displaying all this data at once would not only overwhelm the user but also severely impact the application's performance. This is where pagination steps in, offering a structured and user-friendly approach to data presentation. By breaking down large datasets into smaller, manageable chunks or pages, pagination ensures that users can navigate through the content seamlessly without experiencing performance lags or information overload.

The benefits of implementing pagination extend beyond just user experience. From a technical standpoint, pagination significantly reduces the amount of data transferred and rendered at any given time. This is particularly crucial for applications with limited bandwidth or users on mobile devices. By fetching only the data required for the current page, pagination minimizes network traffic and improves loading times. Furthermore, pagination can enhance the overall responsiveness of the application, as the browser doesn't have to process and render a massive amount of data simultaneously. In essence, pagination is not merely a visual feature but a fundamental optimization technique that contributes to the scalability and performance of Vue.js applications. So, understanding and implementing pagination effectively is paramount for any Vue.js developer aiming to build robust and user-friendly web applications.

Key Concepts of Pagination

Before we dive into the code, let's quickly recap the key concepts behind pagination. At its core, pagination involves dividing a large dataset into smaller, discrete pages. Each page contains a subset of the total data, and users can navigate between these pages to view different portions of the dataset. This approach not only improves performance but also enhances the user experience by making it easier to browse and find specific items.

Several key elements come into play when implementing pagination: Total items: The total number of items in the dataset. This is essential for calculating the total number of pages. Items per page: The number of items displayed on each page. This is a crucial factor in determining the balance between performance and user experience. A larger number of items per page may reduce the number of page requests but can also lead to longer loading times. Current page: The page currently being viewed by the user. This is used to determine which subset of data to fetch and display. Total pages: The total number of pages, calculated by dividing the total items by the items per page (and rounding up to the nearest integer). Page navigation: Controls (such as buttons or links) that allow users to move between pages. These controls typically include options to go to the next page, previous page, first page, and last page, as well as direct links to specific page numbers. Data fetching: The process of retrieving the data for the current page from the server or data source. This often involves sending a request with parameters indicating the desired page number and items per page. Cursor-based pagination: An alternative approach to pagination that uses cursors (pointers to specific items) instead of page numbers. This can be more efficient for large datasets with frequent updates, as it avoids issues with page numbers changing as data is added or removed.

Project Overview and Setup

Before we dive into the code, let's briefly discuss the project overview and setup. We'll be building a pagination composable that can be easily integrated into any Vue.js application. This composable will handle the logic for fetching data, managing pagination state, and providing methods for navigating between pages. To get started, you'll need a Vue.js project set up. If you don't have one already, you can quickly create one using the Vue CLI:

npm install -g @vue/cli
vue create pagination-example
cd pagination-example

Once your project is set up, you can install any additional dependencies you might need, such as Axios for making HTTP requests or a UI library like Vuetify or Element UI for styling your pagination controls. For this example, we'll keep it simple and focus on the core pagination logic, so we won't be using any external libraries.

The project structure will look something like this:

pagination-example/
β”œβ”€β”€ src/
β”‚   β”œβ”€β”€ components/
β”‚   β”‚   └── Pagination.vue # A component to display pagination controls
β”‚   β”œβ”€β”€ composables/
β”‚   β”‚   └── usePagination.js # Our pagination composable
β”‚   β”œβ”€β”€ App.vue
β”‚   └── main.js
β”œβ”€β”€ public/
β”œβ”€β”€ package.json
└── ...

The usePagination.js file will contain the core logic for our pagination composable, while the Pagination.vue component will handle the display of pagination controls and the interaction with the composable. The App.vue component will be used to demonstrate how to integrate the composable and component into a real-world application.

Main Composable: usePaginatedQuery

This is the heart of our pagination solution. This composable, usePaginatedQuery, is designed to encapsulate all the logic required to fetch and display paginated data in a Vue.js application. It leverages the convex-vue library to interact with a Convex backend, but the core concepts can be adapted to other data sources as well. Let's break down the code step by step.

import { useConvexClient } from 'convex-vue'
import type { PaginatedQueryItem } from 'convex/react'
import {
  FunctionArgs,
  FunctionReference,
  getFunctionName,
  PaginationOptions,
  PaginationResult,
} from 'convex/server'
import deepEquals from 'fast-deep-equal'
import {
  computed,
  type MaybeRefOrGetter,
  onScopeDispose,
  type Ref,
  toValue,
} from 'vue'
import {
  type PaginationStatus,
  usePaginatedQueryManager,
} from '~lib/convex/paginatedQueryManager.ts'

export type BetterOmit<T, K extends keyof T> = {
  [Property in keyof T as Property extends K ? never : Property]: T[Property]
}

export type PaginatedQueryReference = FunctionReference<
  'query',
  'public',
  { paginationOpts: PaginationOptions },
  // oxlint-disable-next-line no-explicit-any
  PaginationResult<any>
>

type UsePaginatedQueryReturnType<Query extends PaginatedQueryReference> = {
  results: Readonly<Ref<Array<PaginatedQueryItem<Query>>>>
  loadMore: (numItems: number) => void
  status: Readonly<Ref<PaginationStatus>>
  isLoading: Readonly<Ref<boolean>>
}

export function usePaginatedQuery<Query extends PaginatedQueryReference>(
  query: MaybeRefOrGetter<Query>,
  options: {
    initialNumItems: number
    input?: MaybeRefOrGetter<BetterOmit<FunctionArgs<Query>, 'paginationOpts'>>
    disabled?: MaybeRefOrGetter<boolean>
  },
): UsePaginatedQueryReturnType<Query> {
  if (
    typeof options?.initialNumItems !== 'number'
    || options.initialNumItems < 0
  ) {
    throw new Error(
      `\`options.initialNumItems\` must be a positive number. Received \
      `${options?.initialNumItems}\`.`,
    )
  }

  const client = useConvexClient()

  const queryManager = computed<
    | ReturnType<typeof usePaginatedQueryManager<Query>>
    | null
  >((prev) => {
    // null if disabled. make sure to clean up previous scope, if it exists
    if (toValue(options.disabled)) {
      prev?.stop()
      return null
    }
    // continue using previous value if all dependencies are the same
    if (
      !!prev
      && getFunctionName(toValue(query)) === getFunctionName(prev.query)
      && deepEquals(toValue(options.input), prev.input)
    ) {
      return prev
    }

    // create a new manager
    prev?.stop()
    return usePaginatedQueryManager(
      client,
      toValue(query),
      toValue(options.input),
      toValue(options.initialNumItems),
    )
  })

  onScopeDispose(() => {
    queryManager.value?.stop()
  })

  const status = computed(
    () => queryManager.value?.status.value ?? 'LoadingFirstPage',
  )
  const isLoading = computed(
    () => status.value === 'LoadingFirstPage' || status.value === 'LoadingMore',
  )

  return {
    results: computed(() => queryManager.value?.results.value ?? []),
    status,
    isLoading,
    loadMore: (numItems: number) => queryManager.value?.loadMore(numItems),
    // expose internal state for debugging
    queryManager,
  }
}

// TODO cannot re-export optimistic update helpers from the core convex package because it imports react
// export {
//   optimisticallyUpdateValueInPaginatedQuery,
//   insertAtBottomIfLoaded,
//   insertAtPosition,
//   insertAtTop,
// } from 'convex/react'

Importing Dependencies and Defining Types

First, we import necessary modules from convex-vue, convex/server, vue, and a local file ~lib/convex/paginatedQueryManager.ts. These imports provide us with the tools we need to interact with Convex, manage reactive state in Vue, and handle the complexities of paginated queries. We then define several types to ensure type safety and clarity within our composable. BetterOmit is a utility type that allows us to omit specific keys from a type, which is useful for defining the input options for our query. PaginatedQueryReference defines the type for a Convex query function that supports pagination. UsePaginatedQueryReturnType specifies the structure of the object returned by our composable, including the paginated results, loading status, and methods for loading more data.

Handling Initial Configuration and Input Validation

Next, we define the usePaginatedQuery function, which is the main entry point for our composable. This function takes two arguments: a query (which is a reactive reference to a Convex query function) and an options object. The options object includes initialNumItems (the number of items to fetch initially), an optional input (the input arguments for the query), and an optional disabled flag (to disable the query). We start by validating the initialNumItems option to ensure it's a positive number. This is a crucial step in preventing unexpected behavior and ensuring the composable functions correctly.

Integrating with Convex and Managing Query Execution

Inside the usePaginatedQuery function, we use the useConvexClient composable from convex-vue to obtain a Convex client instance. This client is used to execute queries against our Convex backend. We then define a computed property, queryManager, which is responsible for managing the lifecycle of our paginated query. This computed property uses the usePaginatedQueryManager composable (which we'll discuss in the next section) to handle the actual data fetching and pagination logic. The queryManager computed property is designed to be efficient and avoid unnecessary re-creations. It checks if the query or input options have changed before creating a new manager instance. If the dependencies are the same, it reuses the previous manager instance, preventing unnecessary network requests and improving performance. This optimization is crucial for ensuring a smooth and responsive user experience, especially when dealing with complex queries or frequently changing data.

Managing the Lifecycle and Reactive State

To ensure proper resource management, we use the onScopeDispose hook from Vue to stop the queryManager when the component using the composable is unmounted. This prevents memory leaks and ensures that any ongoing queries are canceled. We also define two computed properties, status and isLoading, to expose the current state of the pagination process. The status property indicates the overall state of the pagination, such as LoadingFirstPage, CanLoadMore, Exhausted, or Error. The isLoading property is a boolean that indicates whether the data is currently being loaded. These computed properties allow the component using the composable to react to changes in the pagination state and update the UI accordingly. For example, the component can display a loading indicator while isLoading is true or show a