Skip to content

Add consistent page-by-page restoration to useInfiniteQuery for restoring paginated data from the cache #10095

@inducingchaos

Description

@inducingchaos

Describe the bug

Escalating this discussion to an issue: #10078

Although each page of an infinite query is not considered a separate query in the query cache - I think it is design flaw that useInfiniteQuery does not paginate when the cached data is restored (it dumps everything into the data prop, rather than waiting for a fetchNextPage call, which should instantaneously provide the next page of data - almost like a simulated fetch).

On a conceptual level... yes, one infinite query that hits the same endpoint for the same data with the same parameters (but different pagination props) seems reasonable to store as one query, rather than multiple queries. Or does it?

While that's debatable, what I think is not is that useInfiniteQuery should consistently handle pagination. Loaded data ≠ loaded/visible rows. One might say: "just manually paginate the data once it's loaded" and sure, there are ways to hack this - but would require rebuilding hasNextPage and fetchNextPage, which would become redundant on first load because useInfiniteQuery does this for us.

So I think the solution to producing a consistent API (and/or solving this problem as-is) is to either remove pagination by eliminating useInfiniteQuery and adapt useQuery to iterate and fetch multiple pages (renders useInfiniteQuery useless - but at least the queries would be paginated consistently... not really the best option), or bake the same functionality into rendering/providing cached data to the data prop in a way that respects the pagination utils - creating a "distribution" proxy layer between the in-memory cache and the data prop to do so.

I hope that makes sense, and that my direction is reasonable. The current approach of "dump all pages in at once, and expect the client to re-implement the pagination props exposed by the useInfiniteQuery hook" feels out of line with the purpose of the hook.

For an explicit "one page at a time" UI where you have to manually select the page, I'm sure this would work fine, because you can just select the page from InfiniteData.

BUT for an infinite-scroll setup that relies on hasNextPage and fetchNextPage to render "loading" rows and only display a page worth of rows - this isn't optimal, because it just displays 100+ rows at once instead of the page size (~25), creating an inconsistent UI experience.

Again, the only way to fix this is to either hack the query cache somehow or re-implement pagination the same way useInfiniteQuery does when it makes network requests.

Here's some code to give an example:

export function useThoughtsQueryOptions(props?: {
    authToken: string | null
    filter?: ThoughtsFilterOptions | null
}) {
    const { authToken = null, filter } = props ?? {}

    return useMemo(
        () =>
            router.thoughts.get.infiniteOptions({
                input: (pageParam: CursorDefinition | null) => ({
                    pagination: {
                        type: "cursor",
                        cursors: pageParam ? [pageParam] : null,
                        limit: config.listPaginationLimit
                    },

                    filter
                }),
                context: { authToken },

                initialPageParam: null,
                getNextPageParam: lastPage => {
                    if (!lastPage.hasMore) return null

                    const thoughts = lastPage.thoughts
                    if (!(thoughts && thoughts.length)) return null

                    const lastThoughtIndex = thoughts.length - 1
                    const lastThought = thoughts[lastThoughtIndex]

                    if (!lastThought) return null

                    const cursorDef: CursorDefinition = {
                        field: "created-at",
                        value: lastThought.createdAt
                    }

                    return cursorDef
                }
            }),
        [authToken, filter]
    )
}

const getThoughtsQueryOptions = useThoughtsQueryOptions({
    authToken,
    filter
})

const { isFetching, data, error, hasNextPage, fetchNextPage, refetch } =
    useInfiniteQuery(getThoughtsQueryOptions)

const thoughts = useMemo(
    () => data?.pages.flatMap(page => page.thoughts ?? []) ?? null,
    [data?.pages]
)

const pagination: RaycastPaginationOptions = useMemo(
    () => ({
        pageSize: config.listPaginationLimit,
        hasMore: hasNextPage,
        onLoadMore: fetchNextPage
    }),
    [hasNextPage, fetchNextPage]
)

The result is perfect pagination when thoughts are loaded over the network - but when loaded from a persisted cache in combination with experimental_createQueryPersister, all of the thoughts appear at once.

Another note: an unfortunate side effect of this is that instead of stale "pages" being reloaded per-page that's viewed/loaded, it reloads ALL 100+ pages even if they're not within the viewport (re: page size).

On second thought, this may somewhat be related to the integration of the persister... but not really, because the persisterFn that we pass to defaultOptions.queries.persister is a standard API of the Query Client, therefore the responsibility for the issue most likely lies on the useInfiniteQuery side, and how it consumes paginated query cache data.

To conclude, I think developing a "faux-loading page distribution proxy" layer between the query cache and the data prop is the solution here.

Your minimal, reproducible example

N/A, can create if absolutely needed. Note: all Tanstack Query pagination examples do not build, throwing error - "Error: turbo.createProject is not supported by the wasm bindings."

Steps to reproduce

See description

Expected behavior

See description

How often does this bug happen?

Every time

Screenshots or Videos

Not needed for understanding

Platform

  • MacOS: 26.0.1
  • Raycast: 1.104.4
  • Tanstack Query: 1.13.4

Tanstack Query adapter

react-query

TanStack Query version

1.13.4

TypeScript version

5.9.3

Additional context

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions