Implementing offset-based pagination with infinite scroll in React Router
A practical guide to implementing offset-based pagination with infinite scroll using React Router loaders and IntersectionObserver.
Background
I recently implemented offset-based pagination with infinite scroll and wanted to share some of the key implementation pieces.
Offset-based pagination refers to fetching a certain range of data via an offset. For example,
imagine you have a dataset containing 100 records. If you wanted to fetch the first 50 rows, your
database query would include a LIMIT clause to constrain the dataset to 50. If we wanted to get
the next 50, we would need to utilise an OFFSET clause (in addition to the LIMIT clause) to skip
50 rows in order to make 51 the starting index. This offsetting behaviour is where this type of
pagination gets its name from.
Walkthrough
To set the scene, I needed to render a table component that would list rows of deployment entries. The purpose of this table was to show past deployments, and since it was essentially a history log, there was no practical limit on how many rows this table could end up holding. This felt troubling without employing some sort of optimisation technique.
Pondering long list optimisations
My first thought was to reach for virtualisation since the implementation was fetching the entire dataset at once, so I thought optimisation could take the form of limiting the number of DOM nodes we render. However, after some thinking, I realised that virtualisation is better suited for fixed datasets — like if you needed to render a completed, static list like the contents of a CSV file (emphasis on the word completed). In this scenario, the deployment log history would continuously grow, so fetching the entire history upfront would become increasingly expensive and impractical over time.
With this in mind, I turned my attention to updating our data loading approach to support pagination
so that we could fetch “chunks” of data instead of the entire dataset. For context, the app I was
working on used React Router so data was provided to the route component via a
loader function. I went with offset-based
pagination here as the deployments we were triggering within the app would take a good ~10-20
minutes to complete, so data changes weren't so dynamic (which I believe is something that
offset-based pagination can fall short on).
“To infinity [scrolling] and beyond”
With the offset-based pagination working, my attention then turned to figuring out what trigger mechanism to use to execute the next batch of data.
I could have added a basic “Load more” button for the user to click on, but both the visual and UX aspect felt clunky to me. Infinite scrolling seemed like the most appropriate technique to employ here and the most natural to what we've become accustomed to online. If a user scrolls to the end of the table, we automatically expect the next data to start fetching immediately. Couple this with a loading indicator and we had a pretty strong user experience!
Implementation
Handling the offset-based pagination
As mentioned earlier, the app used React Router so data fetching was handled in the loader function:
const MAX_ROWS_TO_FETCH = <YOUR_BATCH_SIZE>;
export async function loader({ request }: Route.LoaderArgs) {
const url = new URL(request.url);
const currentOffset = parseInt(url.searchParams.get('offset') ?? '0', 10);
// We were using drizzle for our DB client
const queryResults = await db.query.data.findMany({
..., // The actual query had clauses to filter + sort
limit: MAX_ROWS_TO_FETCH + 1,
offset: currentOffset,
});
// Ensure we're "clipping" the data set length to the max size
const data = queryResults.slice(0, MAX_ROWS_TO_FETCH);
return {
data,
// Evaluate if there is still more data to fetch
hasMoreDataToLoad: queryResults.length > MAX_ROWS_TO_FETCH,
// Calculate the next offset for when we fetch more data
nextOffset: currentOffset + MAX_ROWS_TO_FETCH,
};
}
// The loader data above would then be passed through as a prop in the route component
export default function Page({ loaderData }: Route.ComponentProps) {
const { data, hasMoreDataToLoad, nextOffset } = loaderData;
const fetcher = useFetcher<typeof loader>();
return (
...
<TableComponent
...
onLoadMore={() => {
// Passes through the offset value when we need to refetch
fetcher.load(`/<YOUR_PAGE_ROUTE>?offset=${nextOffset}`);
}}
isLoading={fetcher.state === 'loading'}
hasMoreDataToLoad={hasMoreDataToLoad}
/>
)
}
To implement offset-based pagination, we need to utilise two clauses in a database query — LIMIT
and OFFSET.
In the limit value above, we assign it MAX_ROWS_TO_FETCH + 1; the idea here is that we need a
way to know when we reached the end of the dataset so that we don't attempt to refetch when there is
no data left. By always fetching 1 more than our max, we can infer when we've reached the end of the
dataset. To illustrate this, say our MAX_ROWS_TO_FETCH value was 10, and imagine that the entirety
of data was 20 rows.
The first time the loader executes:
- the offset will be
0so we start at the beginning of dataset - the limit will be 11 (10 + 1)
- we would expect
queryResultsto return 11 rows of data, and ourdataconst slices the queryResults so that we keep to the max 10 size when returning the data - the
hasMoreDataToLoadvariable we're returning checks to see if the queryResults length (11) is greater than MAX_ROWS_TO_FETCH (10) — which it is greater than on the first run - the
nextOffsetvalue is then recalculated to be 10 (0 + 10)
The second time the loader executes, it will have been triggered by a callback function. It triggers
a refetch by loading the same route but with an ?offset query param appended. This is how we
specify the offset value from the front-end; the nextOffset that was being returned from the
loader will be used to pass back into the loader. In this case, 10 would be retrieved from the
request URL search params and used when building out the query — so now we'll offset the start of
the dataset by 10 and start with the 11th row. The limit remains at 11, but since there are only
20 rows of data (and we already fetched 10), queryResults will only return 10 rows of data (rows 11
to 20).
When we re-evaluate the value of hasMoreDataToLoad, we will return false now since 10 is not
greater than our max (10).
Managing state across refetches
For simplicity's sake, the code snippet above didn't include the full implementation so that the
focus could just be on the execution flow. In the actual component, you would need to make use of
useState and useEffect to ensure that the component state was being updated and passed through
correctly on refetches.
export default function Page({ loaderData }: Route.ComponentProps) {
const initialData = loaderData;
const [data, setData] = useState(initialData.data);
const [hasMoreDataToLoad, setHasMoreDataToLoad] = useState(initialData.hasMoreDataToLoad);
const [nextOffset, setNextOffset] = useState(initialData.nextOffset);
const fetcher = useFetcher<typeof loader>();
useEffect(() => {
if (fetcher.data && fetcher.state === 'idle') {
// Important that we're appending to the previous data here
setData((prev) => [...prev, ...fetcher.data.data]);
setHasMoreDataToLoad(fetcher.data.hasMoreDataToLoad);
setNextOffset(fetcher.data.nextOffset);
}
}, [fetcher.data, fetcher.state]);
return (
...
)
}
Infinite scrolling
I used the
IntersectionObserver
here to help detect when our trigger element would come into view.
interface TableComponentProps {
data: unknown[]; // Setting as unknown[] here for example-sake
isLoading: boolean;
hasMoreDataToLoad: boolean;
onLoadMore: () => void;
}
function TableComponent({
data,
isLoading,
hasMoreDataToLoad,
onLoadMore,
}: TableComponentProps) {
const triggerRef = useRef<HTMLTableRowElement>(null);
useEffect(() => {
// Short-circuit if there is an active refetch, or there is no data left
if (isLoading || !hasMoreDataToLoad) {
return;
}
const observer = new IntersectionObserver(
([entry]) => {
// Invoke callback if trigger element comes into view
if (entry.isIntersecting) {
onLoadMore();
}
}
);
if (triggerRef.current) {
observer.observe(triggerRef.current);
}
return () => {
observer.disconnect();
};
}, [isLoading, hasMoreDataToLoad, onLoadMore]);
// Returns a JSX Element for a table component
// We only render the trigger element if there is data left still to fetch
return (
<table>
{data.map((row) => <tr ...>...</tr>)}
{hasMoreDataToLoad && <TriggerElementRow ref={triggerRef} />}
</table>
)
}
The execution here is fairly straightforward compared to the previous section thankfully. We have a
trigger element which just needs to signal some form of loading state feedback to users. I went with
a skeleton component for my scenario and it
would only render at the end of the table when there was data still to fetch. We place an observer
on this element and monitor for an intersection between it and its container — when this happens, we
can infer that the element is visible. When this happens, we invoke the onLoadMore callback that
we passed in which triggers a refetch. This will keep executing as long as there is still more data
to load.
This approach worked really well for my use case, and I hope the walkthrough helps if you're tackling something similar. Thanks for reading!
Read more writing →