Pagination using urql
- #urql
- #react
Libraries:
"@urql/core": "^3.0.5"
Gist: https://gist.github.com/ryansukale/f217810e36dd697e319ce649d4624146.js
Recently while working with URQL, I struggled a bit with implementing pagination since it does not come out of the box at the time of writing. So I went ahead and implemented something that seems to work well in my testing.
My goal was to come up with a pagination solution that would work with any query. I first started by creating a wrapper around the original useQuery
from urql such that it separates data from actions
. I prefer this approach instead of solely relying on positional parameters. It also lets me group together functions that perform a task and expand on that behaviour if need be.
For example, I wrapped up urql's network refresh as a hardRefresh
function - which is a lot more understandable.
import { useMemo } from "react";
import { useQuery } from "urql";
export default function useFetchData(args) {
const [result, refresh] = useQuery(args);
return useMemo(
() => ({
...result,
actions: {
refresh,
hardRefresh: () => {
refresh({ requestPolicy: "network-only" });
},
},
}),
[result, refresh]
);
}
Then I used a usePrevious hook that lets us refer to the previous value of a stateful variable - https://usehooks.com/usePrevious/
The next snippet shows the pagination function itself. This is just an early implementation, and could be turned into a library with some tweaking to configure your own pagination params, but is a good start if you mainly just need to modify the params and use this function in your own code.
There are a few things to note
- The function retains the same interface of
data
andactions
- It exposes an additional action
loadMore
- The pagination parameters being used are
offset
andlimit
. Change them to your liking.
import { useCallback, useState, useEffect, useMemo } from "react";
import { dequal } from "dequal";
import usePrevious from "lib/hooks/usePrevious";
import useFetchData from "lib/hooks/useFetchData";
export default function usePaginatedFetchData(
args,
{ getInitialValue, onDataLoaded, getCanLoadMore, limit = 10 }
) {
const { variables, ...rest } = args;
const [offset, setOffset] = useState(0);
const [data, setData] = useState(getInitialValue);
const [isInitialLoadDone, setIsInitialLoadDone] = useState(true);
const [loadMoreState, setLoadMoreState] = useState({
isRequested: false,
isDone: false,
});
const req = useFetchData({
requestPolicy: "cache-and-network",
...rest,
variables: {
...variables,
offset,
limit,
},
});
const initialVariables = usePrevious(variables);
const hasDifferentVariables = !dequal(initialVariables, variables);
useEffect(() => {
if (req.fetching || req.stale) {
return;
}
if (!isInitialLoadDone) {
setIsInitialLoadDone(true);
setData((d) => onDataLoaded(d, req.data));
return;
}
if (loadMoreState.isRequested) {
setLoadMoreState({
isRequested: false,
isDone: true,
});
setData((d) => onDataLoaded(d, req.data));
}
}, [
req.data,
req.fetching,
req.stale,
onDataLoaded,
loadMoreState,
isInitialLoadDone,
]);
useEffect(() => {
if (hasDifferentVariables) {
setData(getInitialValue);
setIsInitialLoadDone(false);
setLoadMoreState({
isRequested: false,
isDone: false,
});
setOffset(0);
}
}, [hasDifferentVariables, getInitialValue]);
const loadMore = useCallback(() => {
setOffset((offset) => offset + limit);
setLoadMoreState({
isRequested: true,
isDone: false,
});
}, [limit]);
const actions = useMemo(
() => ({ ...req.actions, loadMore }),
[req.actions, loadMore]
);
return useMemo(
() => ({
...req,
data,
isInitialLoadDone,
canLoadMore: isInitialLoadDone ? getCanLoadMore(req.data) : true,
isLoadMoreRequested: loadMoreState.isRequested,
isLoadMoreDone: loadMoreState.isDone,
actions,
}),
[req, data, isInitialLoadDone, getCanLoadMore, loadMoreState, actions]
);
}
Its also a good idea to wrap the pagination criteria of different requests. We are going to build a paginator for that.
export default class Paginator {
key: string;
limit: number;
constructor({ key, limit = 10 }) {
this.key = key;
this.limit = limit;
}
getCanLoadMore = (data) => {
if (!data) {
return true;
}
const { key, limit } = this;
return data[key].length === limit;
};
getInitialValue = () => {
return { [this.key]: [] };
};
onDataLoaded = (existingData, newData) => {
const { key } = this;
return { ...newData, [key]: [...existingData[key], ...newData[key]] };
};
}
With that out of the way, you can now use it in your app as
// 'key' represents the key in your graphql query that needs to be paginated
const paginator = useMemo(() => new Paginator({ key: "search", limit: 5 }), []);
const searchReq = usePaginatedFetchData(
{
query: SEARCH_QUERY,
variables: {
term,
filters: { name: { _in: ["John"] } },
},
},
paginator
);
// And later on
<button
onClick={() => searchReq.canLoadMore && searchReq.actions.loadMore()}
></button>;