DEV Community

ardsh
ardsh

Posted on • Updated on

Implementing Cursor Pagination with tRPC queries

We're gonna continue where we left off in the last article in the series.

I've chosen to demonstrate cursor-based pagination, since it's a more evolved version compared to offset-based, however as you'll see offset-based pagination can be implemented in much the same way, only simpler.

Setup

For this tutorial I'm gonna use slonik-trpc for fetching data from a postgres database. It's this abstraction I created using slonik, to make it easy to build these kinds of tRPC APIs, with pure SQL queries.

With it you can declare any SQL query as a relation, and create an API from that. It handles all the details of pagination with cursors (and offsets), selecting fields, sorting and filtering etc.

You also need a PostgreSQL database to use slonik.
This is a good article for free PostgreSQL databases in the cloud. Once you have a DATABASE_URL you can use it with slonik.

Problem Requirements

We want to be able to load a page in a table of employees. Then, we want to be able to navigate to the next page, and the previous page (two-way cursor pagination).
Finally, we want to be able to jump to the last page, or the first page.

Saving the pagination state

We start by defining the state in a reducer. This is fairly boilerplate, but basically, we're gonna save this state:

type CursorPagination = {
    /** The current page cursor. If empty, we're in the first page.
     * Whenever we change the page, this changes.
    */
    currentCursor?: string,
    /** A counter that keeps track of the current page.
     * Useful if we want to show a page number in the UI */
    currentPage?: number | null,
    /** The first cursor of the current page, as specified by the data source*/
    startCursor?: string,
    /** The end cursor of the current page, as specified by the data source */
    endCursor?: string,
    /** Whether the data source has a next page */
    hasNextPage?: boolean,
    /** Whether the data source has a previous page */
    hasPreviousPage?: boolean,
    /** Whether we're paging backwards (used when going to previous/last page) */
    reverse?: boolean,
    /** The amount of items to take */
    pageSize?: number,
}
Enter fullscreen mode Exit fullscreen mode

And we'll use these actions to change the data:

export type CursorPaginationAction = {
    type: 'UPDATE_DATA',
    // Update cursors when data changes
    data: {
        startCursor?: string | null,
        endCursor?: string | null,
        hasNextPage?: boolean,
        hasPreviousPage?: boolean,
    }
} | {
    type: 'TABLE_CHANGE',
    // Change the page size
    pageSize: number,
} | {
    type: 'FIRST_PAGE',
} | {
    type: 'LAST_PAGE',
} | {
    type: 'NEXT_PAGE',
} | {
    type: 'PREVIOUS_PAGE',
}
Enter fullscreen mode Exit fullscreen mode

The rest of the reducer code is just boilerplate, updating the state based on actions, I'll show the code at the end.

export  function  cursorPaginationReducer(state: CursorPagination = initialCursorPagination, action: CursorPaginationAction): CursorPagination {
    // ...
}
Enter fullscreen mode Exit fullscreen mode

Pagination Component UI

You can build a UI component that has 4 buttons (first, previous, next, last) and a page size dropdown. Its state would be managed by a hook that uses the above reducer, so when rendering we'd do

<CursorPagination
    {...employeeLoader.usePaginationProps()}
Enter fullscreen mode Exit fullscreen mode

The API

The API should return the pageInfo with startCursor, endCursor, hasNextPage and hasPreviousPage.

This API will be built using slonik-trpc. I'll explain the backend part in another article in this series.

Attaching to tableDataLoader

We already have a tableDataLoader for declaring table columns, from the previous article.

Now we need to add two more functions for cursor pagination functionality,usePaginationProps and useUpdateQueryData

export const createTableLoader = <TPayload extends Record<string, any>>() => {
  // ...
  return {
    ContextProvider,
    useVariables,
    usePaginationProps,
    useUpdateQueryData,
    createColumn,
    useColumns
  }
}
Enter fullscreen mode Exit fullscreen mode

useUpdateQueryData

This hook is supposed to be called right after the tRPC query.
The reason we call this is to update the start and end cursors of each page, when they change.

This way, the cursor reducer can be fully responsible for paginating through pages. E.g. when the NEXT_PAGE action is dispatched, the cursor pagination reducer can change its current cursor to be endCursor, or when moving to the previous page, we'd change currentCursor to be startCursor and use a negative take, to be able to query the previous 25 items.

Anyway, the actual update query hook is simple, it just dispatches the UPDATE_DATA action whenever data changes.

useUpdateQueryData: (data?: {
  nodes?: readonly TPayload[] | null,
  pageInfo?: {
    hasNextPage?: boolean,
    hasPreviousPage?: boolean,
    startCursor?: string | null,
    endCursor?: string | null,
  }
}) => {
  const dispatch = React.useContext(DispatchContext);

  React.useEffect(() => {
    if (data) {
      dispatch({
        type: 'UPDATE_DATA',
        data,
      });
    }
  }, [data, dispatch]);
},
Enter fullscreen mode Exit fullscreen mode

Saving the cursor pagination state

I want to compose the entire pagination state inside the table data loader, to make it easier to work with (e.g. by composing the pagination context inside the same ContextProvider, we can only use a single ContextProvider).

There might be better ways to do this, but a simple way is to just call the pagination reducer within the larger state reducer that also saves the dependencies array.

import { CursorPaginationAction, CursorPagination, cursorPaginationReducer } from './useCursorPagination';


type Action = {
  type: "APPEND_FIELDS",
  dependencies: string[]
} | CursorPaginationAction;

const stateReducer = (state: State, action: Action) => {
  switch (action.type) {
    case 'APPEND_FIELDS':
      return {
        ...state,
        // Sort alphabetically to have a stable array
        dependencies: [... new Set(state.dependencies.concat(action.dependencies))].sort(),
      };
    default: return {
      ...state,
      pagination: cursorPaginationReducer(state.pagination, action),
    };
  }
}

export const  createTableLoader = <TPayload  extends  Record<string, any>>() => {
  const initialState = {
    dependencies: [],
    pagination: initialCursorPagination,
  };
  const DependenciesContext = React.createContext([] as (keyof TPayload)[]);
  const PaginationContext = React.createContext(initialCursorPagination);
  const DispatchContext = React.createContext((() => {
    throw new Error("tableDataLoader Context provider not found!");
  }) as React.Dispatch<Action>);

  return {
    ContextProvider: ({ children }: { children: React.ReactNode }) => {
      const [state, dispatch] = React.useReducer(stateReducer, initialState);
      return (<DispatchContext.Provider value={dispatch}>
        <DependenciesContext.Provider value={state.dependencies}>
          <PaginationContext.Provider value={state.pagination}>
            {children}
          </PaginationContext.Provider>
        </DependenciesContext.Provider>
      </DispatchContext.Provider>)
    },
Enter fullscreen mode Exit fullscreen mode

The table data loader is now getting a bit more complicated, but it's still very nice to use because we've split the contexts and grouped them all in a single ContextProvider.

We're gonna need the pagination context provider in the usePaginationProps hook.

usePaginationProps

I want to simply call usePaginationProps and pass its return value down to the CursorPagination component, and not worry about managing the state of specific tables.

const getPaginationProps = employeeLoader.usePaginationProps();

// ...

<CursorPagination {...getPaginationProps()} />
Enter fullscreen mode Exit fullscreen mode

This is very useful when we have lots of different tables, and we don't want to write code for handling each one of them.

So the CursorPagination component is dependent on this hook, which means this hook should return functions like onNext, onPrevious, and currentPage, all things that are stored within our cursor pagination state, or are action dispatcher functions (e.g. onNext).

So implementation would be something like this:

usePaginationProps: () => {
  const dispatch = React.useContext(DispatchContext);
  const pagination = React.useContext(PaginationContext);

  const getPaginationProps = React.useCallback(() => ({
    onNext: () => dispatch({ type: 'NEXT_PAGE' }),
    onPrevious: () => dispatch({ type: 'PREVIOUS_PAGE' }),
    onLast: () => dispatch({ type: 'LAST_PAGE' }),
    onFirst: () => dispatch({ type: 'LAST_PAGE' }),
    onPageSizeChange: (pageSize: number) => dispatch({ type: 'TABLE_CHANGE', pageSize }),
    currentPage: pagination.currentPage,
  }), [dispatch, pagination]);

  return getPaginationProps;
},
Enter fullscreen mode Exit fullscreen mode

Usage

We change the useVariables hook to include take and cursor, so that variables change whenever the current pagination cursor changes, and the page data is then fetched.

useVariables: () => {
  const dependencies = React.useContext(DependenciesContext);
  const { currentCursor, pageSize = 25, reverse } = React.useContext(PaginationContext);

  return React.useMemo(() => ({
    select: dependencies,
    take: reverse ? -pageSize : pageSize,
    takeCursors: true,
    cursor: currentCursor,
  }), [dependencies, currentCursor, reverse, pageSize]);
},
Enter fullscreen mode Exit fullscreen mode

When using it in the component, we just have to add an useUpdateQueryData call after getting the data

const pagination = employeeTableLoader.useVariables();
const { data, isLoading } = trpc.employees.getPaginated.useQuery({
    ...pagination,
});
employeeTableLoader.useUpdateQueryData(data);
Enter fullscreen mode Exit fullscreen mode

Complete Implementation

You can see a similar example implementation at slonik-trpc/examples/datagrid-example

useCursorPagination

This is the complete implementation of useCursorPagination actions and reducer.

import React from "react";

export interface GetCursorPaginationProps {
    (): CursorPagination
}

export const initialCursorPagination: CursorPagination = {
    hasNextPage: false,
    currentPage: 1,
    hasPreviousPage: false,
    currentCursor: '',
    startCursor: '',
    endCursor: '',
    reverse: false,
    pageSize: 25,
}

export type CursorPaginationAction = {
    type: 'UPDATE_DATA',
    data: {
        startCursor?: string | null,
        endCursor?: string | null,
        hasNextPage?: boolean,
        hasPreviousPage?: boolean,
    }
} | {
    type: 'TABLE_CHANGE',
    pageSize: number,
} | {
    type: 'FIRST_PAGE',
} | {
    type: 'LAST_PAGE',
} | {
    type: 'NEXT_PAGE',
} | {
    type: 'PREVIOUS_PAGE',
}

export type CursorPagination = {
    /** The current page cursor. If empty, we're in the first page.
     * Whenever we change the page, this changes.
    */
    currentCursor?: string,
    /** A counter that keeps track of the current page.
     * Useful if we want to show a page number in the UI */
    currentPage?: number | null,
    /** The first cursor of the current page, as specified by the data source*/
    startCursor?: string,
    /** The end cursor of the current page, as specified by the data source */
    endCursor?: string,
    /** Whether the data source has a next page */
    hasNextPage?: boolean,
    /** Whether the data source has a previous page */
    hasPreviousPage?: boolean,
    /** Whether we're paging backwards (used when going to previous/last page) */
    reverse?: boolean,
    /** The amount of items to take */
    pageSize?: number,
}

export function cursorPaginationReducer(state: CursorPagination = initialCursorPagination, action: CursorPaginationAction): CursorPagination {
    switch (action.type) {
        case 'TABLE_CHANGE':
            return {
                ...state,
                currentCursor: '', // Go to first page
                currentPage: 1,
                hasNextPage: true,
                hasPreviousPage: false,
                reverse: false,
                pageSize: action.pageSize,
            }
        case 'UPDATE_DATA':
            return {
                ...state,
                ...(action.data?.startCursor && { startCursor: action.data.startCursor }),
                ...(action.data?.endCursor && { endCursor: action.data.endCursor }),
                hasNextPage: action.data?.hasNextPage,
                hasPreviousPage: action.data?.hasPreviousPage,
            }
        case 'NEXT_PAGE':
            return {
                ...state,
                // Increment current page tracker if currentPage wasn't null
                currentPage: state.currentPage !== null && state.currentCursor !== state.endCursor ?
                    (state.currentPage || 1) + 1 : null,
                hasPreviousPage: true,
                hasNextPage: false,
                currentCursor: state.endCursor,
                reverse: false,
            }
        case 'PREVIOUS_PAGE':
            return {
                ...state,
                // Keep track of current page in the counter
                currentPage: state.currentPage !== null && state.currentCursor !== state.startCursor ?
                    Math.max(1, (state.currentPage || 1) - 1) : null,
                hasNextPage: true,
                hasPreviousPage: false,
                currentCursor: state.startCursor,
                reverse: true,
            }
        case 'LAST_PAGE':
            return {
                ...state,
                // Setting currentPage to null when going to last page because total count is unknown
                currentPage: null,
                hasPreviousPage: true,
                hasNextPage: false,
                currentCursor: '',
                reverse: true,
            }
        case 'FIRST_PAGE':
            return {
                ...state,
                currentPage: 1,
                hasNextPage: true,
                hasPreviousPage: false,
                currentCursor: '',
                reverse: false,
            }
        default:
            return state;
    }
}
Enter fullscreen mode Exit fullscreen mode

Notes

One thing to be careful here is when sorting data with different columns, the old cursor won't be stable, as it will refer to a different sorting configuration (read the slack cursor pagination article for more details on how base64 encoded cursors work).

So when we change the sorting columns, e.g. when sorting by salary after we've sorted by name, the cursor needs to be reset as well.
Usually that means going to the first page (an empty cursor means we're in the first page).

What's next?

Stay tuned for the next articles in this series, which will explain more in detail how to build the backend of this API, and also different patterns for tRPC queries selective type-safety.

Top comments (0)