article (Tue May 02 2023)

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 and actions
  • It exposes an additional action loadMore
  • The pagination parameters being used are offset and limit. 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>;