Offset-based pagination
We recommend reading Core pagination API before learning about considerations specific to offset-based pagination.
Offset-based pagination works well for immutable lists, or lists whose element positions are not expected to change, since moving or removing elements could alter the offsets of the elements in the list, sometimes causing elements to be skipped or duplicated if the list is modified on the server between page requests.
Although offset-based pagination has these shortcomings, it is a common pattern found in many applications, in part because it is straightforward to implement on the backend. In SQL, for example, numbered pages can easily be generated by using OFFSET and LIMIT.
The offsetLimitPagination
helper
Common pagination strategies can be abstracted away into helper functions, such as the offsetLimitPagination
function that @apollo/client/utilities
provides:
import { offsetLimitPagination } from "@apollo/client/utilities"
const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
feed: offsetLimitPagination(["type"]),
},
},
},
});
The ["type"]
argument here specifies the keyArgs
to be used for this field policy. By default, offsetLimitPagination
uses keyArgs: false
.
If you set the field policy above for Query.feed
, then you can use fetchMore
with useQuery
like so:
const FeedData({ type = "PUBLIC" }) {
const { loading, data, fetchMore } = useQuery(FEED_QUERY, {
variables: {
type: type.toUpperCase(),
offset: 0,
limit: 10
},
});
// If you want your component to rerender with loading:true whenever
// fetchMore is called, add notifyOnNetworkStatusChange:true to the
// options you pass to useQuery above.
if (loading) return <Loading/>;
return (
<Feed
entries={data.feed || []}
onLoadMore={() => fetchMore({
variables: {
offset: data.feed.length
},
})}
/>
);
}
By default, fetchMore
uses the original query
and variables
, so we only need to pass the variable that is changing: the offset
. Once the new data is returned from the server, it will be automatically merged with any existing Query.feed
data in the cache, which will cause useQuery
to rerender with the expanded list of data.
This style of fetchMore
usage assumes you want your component to receive the entire available list each time it renders, containing data from all pages received so far. This is a non-paginated read
function.
If you are using a Query.feed
field policy containing a read
function that uses args.offset
and args.limit
to return a single page of data, the code above will still work, but you may be surprised that your component does not automatically rerender with additional data (beyond the first page) after fetchMore
finishes. This happens because the original variables: { offset: 0, limit: 10 }
are still in effect, and the first 10 items were not changed by the fetchMore
call, so your read
function returns the same page as before.
Before you can fix this problem, you first need to think about the behavior that you want. Should your component continue displaying only the first page, or should it now display the page we just received, or should it display the entire list of known data? Regardless of which option you prefer, these alternatives all boil down to the variables
you pass to useQuery
, which must change if you want your component to render different data.
For example, to display all the data received so far, you could modify the previous example as follows:
const FeedData({ type = "PUBLIC" }) {
const [limit, setLimit] = useState(10);
const { loading, data, fetchMore } = useQuery(FEED_QUERY, {
variables: {
type: type.toUpperCase(),
offset: 0,
limit,
},
});
if (loading) return <Loading/>;
return (
<Feed
entries={data.feed || []}
onLoadMore={() => {
const currentLength = data.feed.length;
fetchMore({
variables: {
offset: currentLength,
limit: 10,
},
}).then(fetchMoreResult => {
// Update variables.limit for the original query to include
// the newly added feed items.
setLimit(currentLength + fetchMoreResult.data.feed.length);
});
}
/>
);
}
This code uses a React useState
Hook to store the current limit
value, which it updates by calling setLimit
in a callback attached to the Promise
returned by fetchMore
.
You could store offset
in a React useState
Hook as well, if you need the offset
to change. Exactly when and how these variables
change is up to your component, and may not always be the result of calling fetchMore
, so it makes sense to use React component state to store these variable values.
If you are not using React and
useQuery
, theObservableQuery
object returned byclient.watchQuery
has a method calledsetVariables
that you can call to update the original variables.
Because fetchMore
requires some extra work to update the original variables if you're using a read
function that is sensitive to those variables (the second kind of read
function), it's fair to say fetchMore
encourages the first kind of read
function, which simply returns all available data.
However, now that you understand your options, there's nothing wrong with moving read-time pagination logic out of your application code and into your field read
functions. Both kinds of read
functions have their uses, and both can be made to work with fetchMore
.