Skip to content
Kevin Dang
December 18, 2025

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 0 so we start at the beginning of dataset
  • the limit will be 11 (10 + 1)
  • we would expect queryResults to return 11 rows of data, and our data const slices the queryResults so that we keep to the max 10 size when returning the data
  • the hasMoreDataToLoad variable 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 nextOffset value 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 →