Skip to content

Using RTK Infinite Query For Tabular Pagination #4936

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
elberttimothy opened this issue Apr 9, 2025 · 3 comments
Closed

Using RTK Infinite Query For Tabular Pagination #4936

elberttimothy opened this issue Apr 9, 2025 · 3 comments

Comments

@elberttimothy
Copy link

elberttimothy commented Apr 9, 2025

Hi Redux maintainers,

Very grateful for the new RTK Infinite Query API. I think this solves a lot of the pain points with manually merging paginated requests and cursor management. However, given that it's mainly built for infinite scrolling UIs, we've had to implement our own logic to make this API usable for tabular pagination.

The code snippet below highlights how we're trying to go about this. Almost every table in our app are currently server-side paginated and so we're trying to opt for a higher-order hook (hook factory) pattern to wrap the infinite query hook with all the extra components that our tables need.

However, there are some pain points when working with the types in the current version of the package. Namely, TypedUseInfiniteQuery under generic conditions only returns:

  • refetch
  • fetchNextPage
  • fetchPreviousPage

I don't understand why data for instance, could not just be returned as InfiniteData<ResultType, PageParam> or why the booleans like isFetching and hasNextPage are not there.

Any advice on how to go about this? Perhaps this is the wrong pattern to use?

import {
  type BaseQueryFn,
  type InfiniteData,
  type TypedUseInfiniteQuery,
} from '@reduxjs/toolkit/query/react';
import { useMemo, useRef, useState } from 'react';

type PaginationState = {
  pageIndex: number;
  pageSize: number;
};

export type CreatePaginatedQueryHookPageParam = {
  limit?: number;
};

export const createPaginatedInfiniteQueryHook = <
  ResultType,
  QueryArg,
  PageParam extends CreatePaginatedQueryHookPageParam,
  BaseQuery extends BaseQueryFn,
>(
  useInfiniteQuery: TypedUseInfiniteQuery<
    ResultType,
    QueryArg,
    PageParam,
    BaseQuery
  >,
) => {
  type UseInfiniteQueryParameters = Parameters<
    TypedUseInfiniteQuery<ResultType, QueryArg, PageParam, BaseQuery>
  >;
  type UsePaginatedQueryArgs = UseInfiniteQueryParameters[0];
  type UsePaginatedQuerySubscriptionOptions = Extract<
    UseInfiniteQueryParameters[1],
    object
  >;
  
  // this is the returned pagination hook
  return (
    queryArgs: UsePaginatedQueryArgs,
    subscriptionOptions: UsePaginatedQuerySubscriptionOptions,
  ) => {
    const { initialPageParam } = subscriptionOptions;
    if (!initialPageParam?.limit) {
      throw new Error(
        'Paginated query hook requires initialPageParam with limit',
      );
    }

    // client-side pagination state for components to display
    const furthestPageIndex = useRef(0);
    const [{ pageIndex, pageSize }, setPaginationState] =
      useState<PaginationState>({
        pageIndex: 0,
        pageSize: initialPageParam.limit,
      });
    const { refetch, fetchNextPage, ...result } = useInfiniteQuery(
      queryArgs,
      subscriptionOptions,
    );

    /** Go forwards by 1 page. */
    const getNextPage = async () => {
      if (!result.hasNextPage) return;
      if (pageIndex + 1 > furthestPageIndex.current) {
        await fetchNextPage();
        furthestPageIndex.current += 1;
      }
      setPaginationState(({ pageSize, pageIndex }) => ({
        pageSize,
        pageIndex: pageIndex + 1,
      }));
    };

    /**
     * Go back by 1 page.
     */
    const getPrevPage = async () => {
      if (pageIndex <= 0) return;
      setPaginationState(({ pageSize, pageIndex }) => ({
        pageSize,
        pageIndex: pageIndex - 1,
      }));
    };

    /**
     * Changing the limit will always reset pagination to `pageIndex` 0.
     */
    const changeLimitAndResetPagination = async (newLimit: number) => {
      furthestPageIndex.current = 0;
      setPaginationState({
        pageIndex: 0,
        pageSize: newLimit,
      });
      await refetch();
    };

    /**
     * Consumers should call this when `QueryArgs/PageParams` for `useInfiniteQuery` changes.
     */
    const refetchAndResetPagination = async () => {
      furthestPageIndex.current = 0;
      setPaginationState(({ pageSize }) => ({
        pageIndex: 0,
        pageSize: pageSize,
      }));
      await refetch();
    };
    
    // we have to typecast data here
    const data =
      'data' in result
        ? (result.data as InfiniteData<ResultType, PageParam>)
        : undefined;

    const currentPage = useMemo(() => {
      if (data) {
        return data.pages[pageIndex];
      }
    }, [pageIndex, data]);

    return {
      getNextPage,
      getPrevPage,
      changeLimitAndResetPagination,
      refetchAndResetPagination,
      currentPage,
      paginationState: {
        pageIndex,
        pageSize,
        canGetNextPage: result.hasNextPage as boolean,
        canGetPrevPage: pageIndex > 0,
      },
      infiniteQueryResults: result,
    };
  };
};
@markerikson
Copy link
Collaborator

@EskiMojo14 and I have been poking at this.

It seems like the issue is actually with an inference problem when combining the way TypedUseInfiniteQuery tries to infer the hook result types, and the way your generic wrapper is inferring types via Parameters and then passing that as part of the subscriptionOptions object.

If you try commenting out subscriptionOptions, you should see that the result actually has all the expected remaining fields:

Image

Per Ben:

the basic gist is that ReturnType/Parameters with generic functions is always messy

So, don't have a specific answer atm, but that seems to be the root of the issue.

@elberttimothy
Copy link
Author

Hey man,

Really appreciate you looking into this. You brought up an interesting point about Parameters being funky with generics. I looked into some of the types a bit more and using the TypedUseInfiniteQueryStateOptions generic for subscriptionOptions seemed to work!

Here's a code snippet of the more refined implementation. We basically realised that we need an API that's more similar to useLazyQuery and this is what we came up with.

import {
  type BaseQueryFn,
  type TypedUseInfiniteQuery,
  type TypedUseInfiniteQueryStateOptions,
} from '@reduxjs/toolkit/query/react';
import { useEffect, useMemo, useState } from 'react';

import { type CreatePaginatedInfiniteQueryHookConfig } from './create-paginated-infinite-query-hook.types';

export const createPaginatedInfiniteQueryHook = <
  ResultType,
  QueryArg,
  PageParam,
  BaseQuery extends BaseQueryFn,
  UseInfiniteQuery extends TypedUseInfiniteQuery<
    ResultType,
    QueryArg,
    PageParam,
    BaseQuery
  > = TypedUseInfiniteQuery<ResultType, QueryArg, PageParam, BaseQuery>,
>(
  useInfiniteQuery: TypedUseInfiniteQuery<
    ResultType,
    QueryArg,
    PageParam,
    BaseQuery
  >,
  {
    getLimitFromQueryArg,
    injectLimitIntoQueryArg,
  }: CreatePaginatedInfiniteQueryHookConfig<Parameters<UseInfiniteQuery>[0]>,
) => {
  // hook-scoped type aliases
  type UseInfiniteQueryParameters = Parameters<UseInfiniteQuery>;
  type UsePaginatedQueryArg = UseInfiniteQueryParameters[0];

  // construct the type instead of relying on `Parameters`
  type UsePaginatedQuerySubscriptionOptions = TypedUseInfiniteQueryStateOptions<
    ResultType,
    QueryArg,
    PageParam,
    BaseQuery
  >;

  return (
    initialLimit: number,
    initialQueryArg: UsePaginatedQueryArg,
    subscriptionOptions?: UsePaginatedQuerySubscriptionOptions,
  ) => {
    const [pageIndex, setPageIndex] = useState(0);
    const [queryArg, setQueryArg] = useState(() =>
      injectLimitIntoQueryArg(initialQueryArg, initialLimit),
    );

    const result = useInfiniteQuery(queryArg, subscriptionOptions);
    const { fetchNextPage, refetch, data, isFetchingNextPage } = result;
    const { pages } = data ?? { pages: [] };

    const currentPage = useMemo(() => {
      if (pages) {
        return pages[pageIndex];
      }
    }, [pageIndex, pages]);

    useEffect(() => {
      refetch();
      // eslint-disable-next-line react-hooks/exhaustive-deps
    }, [queryArg]);

    /** Go forwards by 1 page. */
    const getNextPage = async () => {
      if (isFetchingNextPage) return;
      const nextPageAlreadyFetched = pageIndex + 1 <= pages.length - 1;

      if (nextPageAlreadyFetched) {
        setPageIndex((prevPageIndex) => prevPageIndex + 1);
      } else if (result.hasNextPage) {
        await fetchNextPage();
        setPageIndex((prevPageIndex) => prevPageIndex + 1);
      }
    };

    /** Go back by 1 page. */
    const getPrevPage = async () => {
      if (pageIndex <= 0) return;
      setPageIndex((prevPageIndex) => prevPageIndex - 1);
    };

    /**
     * Changing the limit will always reset pagination to `pageIndex` 0.
     */
    const changeLimit = async (newLimit: number) => {
      setPageIndex(0);
      setQueryArg((prevQueryArg) =>
        injectLimitIntoQueryArg(prevQueryArg, newLimit),
      );
    };

    /**
     * Call when `QueryArg` for `useInfiniteQuery` changes.
     */
    const refetchWithNewArgs = async (newQueryArg: UsePaginatedQueryArg) => {
      // use the new limit from `newQueryArg` if it exists - otherwise preserve the old limit
      const newLimit =
        getLimitFromQueryArg(newQueryArg) ??
        getLimitFromQueryArg(queryArg) ??
        initialLimit;
      setPageIndex(0);
      setQueryArg(injectLimitIntoQueryArg(newQueryArg, newLimit));
    };

    return {
      getNextPage,
      getPrevPage,
      changeLimit,
      refetchWithNewArgs,
      currentPage,
      paginationState: {
        pageIndex,
        pageSize: getLimitFromQueryArg(queryArg) ?? initialLimit,
        canGetNextPage:
          (pageIndex + 1 <= pages.length - 1 || result.hasNextPage) &&
          !isFetchingNextPage,
        canGetPrevPage: pageIndex > 0,
      },
      infiniteQueryResults: result,
    };
  };
};

An interesting observation is that we noticed the reference to refetch isn't stable between renders. In this previous iteration:

import {
  type BaseQueryFn,
  type TypedUseInfiniteQuery,
  type TypedUseInfiniteQueryStateResult,
} from '@reduxjs/toolkit/query/react';
import { useMemo, useState } from 'react';

import { type CreatePaginatedInfiniteQueryHookConfig } from './create-paginated-infinite-query-hook.types';

type PaginationContext<QueryArg> = {
  pageIndex: number;
  queryArg: QueryArg;
};

export const createPaginatedInfiniteQueryHook = <
  ResultType,
  QueryArg,
  PageParam,
  BaseQuery extends BaseQueryFn,
  UseInfiniteQuery extends TypedUseInfiniteQuery<
    ResultType,
    QueryArg,
    PageParam,
    BaseQuery
  > = TypedUseInfiniteQuery<ResultType, QueryArg, PageParam, BaseQuery>,
>(
  useInfiniteQuery: TypedUseInfiniteQuery<
    ResultType,
    QueryArg,
    PageParam,
    BaseQuery
  >,
  {
    getLimitFromQueryArg,
    injectLimitIntoQueryArg,
  }: CreatePaginatedInfiniteQueryHookConfig<Parameters<UseInfiniteQuery>[0]>,
) => {
  // hook-scoped type aliases
  type UseInfiniteQueryParameters = Parameters<UseInfiniteQuery>;
  type UsePaginatedQueryArg = UseInfiniteQueryParameters[0];
  type UsePaginatedQuerySubscriptionOptions = UseInfiniteQueryParameters[1];

  type UsePaginatedQueryResult = TypedUseInfiniteQueryStateResult<
    ResultType,
    QueryArg,
    PageParam,
    BaseQuery
  > &
    ReturnType<UseInfiniteQuery>;

  return (
    initialLimit: number,
    initialQueryArg: UsePaginatedQueryArg,
    subscriptionOptions?: UsePaginatedQuerySubscriptionOptions,
  ) => {
    const [{ pageIndex, queryArg }, setPaginationContext] = useState<
      PaginationContext<UsePaginatedQueryArg>
    >({
      pageIndex: 0,
      queryArg: injectLimitIntoQueryArg(initialQueryArg, initialLimit),
    });

    const result = useInfiniteQuery(
      queryArg,
      subscriptionOptions,
    ) as UsePaginatedQueryResult;
    const { fetchNextPage, refetch } = result;
    const data = 'data' in result ? result.data : undefined;
    const { pages } = data ?? { pages: [] };

    /** Go forwards by 1 page. */
    const getNextPage = async () => {
      if (pageIndex + 1 > pages.length - 1 && result.hasNextPage) {
        await fetchNextPage();
      } else if (pageIndex + 1 > pages.length - 1) {
        return;
      }

      setPaginationContext(({ pageIndex, ...rest }) => ({
        ...rest,
        pageIndex: pageIndex + 1,
      }));
    };

    /** Go back by 1 page. */
    const getPrevPage = async () => {
      if (pageIndex <= 0) return;
      setPaginationContext(({ pageIndex, ...rest }) => ({
        ...rest,
        pageIndex: pageIndex - 1,
      }));
    };

    /**
     * Changing the limit will always reset pagination to `pageIndex` 0.
     */
    const changeLimit = async (newLimit: number) => {
      setPaginationContext({
        pageIndex: 0,
        queryArg: injectLimitIntoQueryArg(queryArg, newLimit),
      });

      /**
       * `refetch` cannot occur within the same execution context as `setPaginationContext`.
       * When `refetch` is called, `useInfiniteQuery` needs to have already been rendered with
       * the new `queryArg`. Calling it synchronously with `setPaginationContext` in the
       * event handler will batch the updates - such that `refetch` is called before
       * `useInfiniteQuery` has been rendered with the new `queryArg`.
       *
       * Calling `refetch` in `setTimeout` will schedule `refetch` to be called after the
       * update to `paginationContext` has been rendered. This ensures that the query is called
       * with the latest `queryArg`.
       */
      return new Promise((resolve) => {
        setTimeout(async () => {
          const result = await refetch();
          resolve(result);
        });
      });
    };

    /**
     * Call when `QueryArg` for `useInfiniteQuery` changes.
     */
    const refetchWithNewArgs = async (newQueryArg: UsePaginatedQueryArg) => {
      // use the new limit from `newQueryArg` if it exists - otherwise preserve the old limit
      const newLimit =
        getLimitFromQueryArg(newQueryArg) ??
        getLimitFromQueryArg(queryArg) ??
        initialLimit;
      setPaginationContext({
        pageIndex: 0,
        queryArg: injectLimitIntoQueryArg(newQueryArg, newLimit),
      });

      /**
       * `refetch` cannot occur within the same execution context as `setPaginationContext`.
       * When `refetch` is called, `useInfiniteQuery` needs to have already been rendered with
       * the new `queryArg`. Calling it synchronously with `setPaginationContext` in the
       * event handler will batch the updates - such that `refetch` is called before
       * `useInfiniteQuery` has been rendered with the `newQueryArg`.
       *
       * Calling `refetch` in `setTimeout` will schedule `refetch` to be called after the
       * update to `paginationContext` has been rendered. This ensures that the query is called
       * with the latest `queryArg`.
       */
      return new Promise((resolve) => {
        setTimeout(async () => {
          const result = await refetch();
          resolve(result);
        });
      });
    };

    const currentPage = useMemo(() => {
      if (pages) {
        return pages[pageIndex];
      }
    }, [pageIndex, pages]);

    return {
      getNextPage,
      getPrevPage,
      changeLimit,
      refetchWithNewArgs,
      currentPage,
      paginationState: {
        pageIndex,
        pageSize: getLimitFromQueryArg(queryArg) ?? initialLimit,
        canGetNextPage: pageIndex + 1 <= pages.length - 1 || result.hasNextPage,
        canGetPrevPage: pageIndex > 0,
      },
      infiniteQueryResults: result,
    };
  };
};

We deferred calling refetch to the next render by using setTimeout instead of useEffect for the sake of having everything happen within the event handler. However, we found it quite interesting that the old refetch reference captured in the setTimeout callback triggered a call with the new QueryArgs. We assumed initially that the reference to refetch was changing because it needed to get bound to the most recent QueryArgs after every render but that does not seem to be the case.

Probably something worth looking into?

@markerikson
Copy link
Collaborator

markerikson commented Apr 12, 2025

Yeah, just merged #4937 , which should make both the fetch methods and refetch stable.

Per the behavior, the refetch does it based off the internal promiseRef, which has the latest query arg, so that seems expected.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants