Implementing Cursor Pagination with tRPC queries
ardsh
Posted on January 17, 2023
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,
}
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',
}
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 {
// ...
}
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()}
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
}
}
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]);
},
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>)
},
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()} />
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;
},
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]);
},
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);
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;
}
}
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.
Posted on January 17, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.