Skip to content
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

[DRAFT] RTKQ Infinite Query integration #4738

Draft
wants to merge 65 commits into
base: master
Choose a base branch
from

Conversation

markerikson
Copy link
Collaborator

@markerikson markerikson commented Nov 29, 2024

Overview

This is a running integration PR for the upcoming RTK Query support for "infinite / paginated queries".

The overall plan is to reimplement React Query's public API for infinite queries on top of RTK Query's internals.

This work was started by @riqts in [API Concept] - Infinite Query API. I picked up that work, rebased it, and have fleshed out the TS types, tests, and implementation. See that PR for the original discussion and progress. Also, thanks to @TkDodo for the advice on how they designed the infinite query API for React Query!

My current somewhat ambitious goal is to ship a final version of infinite query support for RTKQ by the end of this year "early" 2025. I am absolutely not going to guarantee that :) It's entirely dependent on how much free time I have to dedicate to this effort, how complicated this turns out to be, and how much polish is needed. But in terms of maintenance effort, shipping this is now my main priority!

Status

It currently works sufficiently that it's ready to be tried out in example apps so that we can confirm it works across a variety of use cases, and find bugs or areas that need work. It's not ready for an official alpha yet.

You can try installing the PR preview build using the installation instructions from the "CodeSandbox CI" job listed at the bottom of the PR. Please leave comments and feedback here!

I've got a laundry list of examples and tests that ought to be added - see the "Todos" section below. Contributions are appreciated!

Preview Builds

Open the "CodeSandbox CI" details check:

Click the most recent commit on the left, then copy-paste the install instructions for your package manager from the "Local Install Instructions" section on the right, such as:

# yarn 1
yarn add https://pkg.csb.dev/reduxjs/redux-toolkit/commit/1e5789f1/@reduxjs/toolkit 

# yarn 2, 3
yarn add @reduxjs/toolkit@https://pkg.csb.dev/reduxjs/redux-toolkit/commit/1e5789f1/@reduxjs/toolkit/_pkg.tgz 

# npm
npm i https://pkg.csb.dev/reduxjs/redux-toolkit/commit/1e5789f1/@reduxjs/toolkit 

Todos

Jotting down some todos as I think of them:

Functionality

  • Auto-generate infinite query hooks (types and runtime)
  • Refetching
    • refetching with hooks?
  • Max pages
    • enforce both gN/PPP when maxPages > 0
  • isFetchingNext/PreviousPage flags
  • hasNext/PreviousPage flags
  • Investigate moving pageParams into some new metadata field in the cache entry, so that it's not directly exposed to the user in data (per discussion with Dominik) This probably still makes sense to keep with the data in case of users upserting
  • Possibly some kind of combinePages option, so that you don't have to do selectFromResult: ({data}) => data.pages.flat() (and memoize it) for every endpoint let's not worry about this for the initial release
  • See how much of the types and logic can be deduplicated
  • transformResponse interactions
  • merge interactions doesn't make sense conceptually for infinite query endpoints
  • Remove dead infiniteQuerySlice
  • Move fetchNext/PreviousPage into thunk results maybe doable, but complicated

Tests / Example Use Cases

React Query examples

ref:

Other

  • optimistic updates? (what does this even look like, conceptually and usage-wise?)
  • upsertQueryData / upsertQueryEntries?
  • RN FlatList
  • CRUD edits to pages?
  • Tag invalidation of an infinite endpoint
  • Query promise thunks

Copy link

codesandbox bot commented Nov 29, 2024

Review or Edit in CodeSandbox

Open the branch in Web EditorVS CodeInsiders

Open Preview

Copy link

codesandbox-ci bot commented Nov 29, 2024

This pull request is automatically built and testable in CodeSandbox.

To see build info of the built libraries, click here or the icon next to each commit SHA.

Latest deployment of this branch, based on commit dbce780:

Sandbox Source
@examples-query-react/basic Configuration
@examples-query-react/advanced Configuration
@examples-action-listener/counter Configuration
rtk-esm-cra Configuration

Copy link

github-actions bot commented Nov 29, 2024

size-limit report 📦

Path Size
1. entry point: @reduxjs/toolkit/query/react (modern.mjs) 14.37 KB (+6.05% 🔺)
2. entry point: @reduxjs/toolkit/query/react (without dependencies) (modern.mjs) 56 B (-22.23% 🔽)
1. entry point: @reduxjs/toolkit/query (cjs, production.min.cjs) 23.2 KB (+3.4% 🔺)
1. entry point: @reduxjs/toolkit/query/react (cjs, production.min.cjs) 25.62 KB (+4.56% 🔺)
2. entry point: @reduxjs/toolkit/query (without dependencies) (cjs, production.min.cjs) 10.02 KB (+8.78% 🔺)
2. entry point: @reduxjs/toolkit/query/react (without dependencies) (cjs, production.min.cjs) 3.08 KB (+6.93% 🔺)
3. createApi (.modern.mjs) 14.77 KB (+5.89% 🔺)
3. createApi (react) (.modern.mjs) 16.81 KB (+7.28% 🔺)

Copy link

netlify bot commented Nov 29, 2024

Deploy Preview for redux-starter-kit-docs ready!

Name Link
🔨 Latest commit dbce780
🔍 Latest deploy log https://app.netlify.com/sites/redux-starter-kit-docs/deploys/678ec682c51018000824710c
😎 Deploy Preview https://deploy-preview-4738--redux-starter-kit-docs.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify site configuration.

@markerikson
Copy link
Collaborator Author

@remus-selea you reported over in #4393 (comment) that:

I've noticed that upsertQueryData no longer behaves as it used to. I verified with the Redux DevTools extension and it now removes the data field entirely for the query.

I just tried reproducing that and I'm not seeing it happen with the current PR. It's entirely possible my test doesn't match what you were doing. Could you give me a repro?

@markerikson
Copy link
Collaborator Author

Knocked out maxPages and refetching. Good progress for the day!

@markerikson
Copy link
Collaborator Author

markerikson commented Dec 1, 2024

Added an example app that ports over the main React Query sandboxes for infinite queries. (Technically "pagination" didn't use an infinite query, and in fact Dominik says you shouldn't use an infinite query for pagination, but I'd already gotten something working so I kept it.)

In the process I found out that:

  • we're not exporting auto-generated hooks at the root API object level for infinite queries (at least at the types level, and maybe not for runtime either)
  • definitely not calculating hasNext/PrevPage either

@remus-selea
Copy link

remus-selea commented Dec 8, 2024

@remus-selea you reported over in #4393 (comment) that:

I've noticed that upsertQueryData no longer behaves as it used to. I verified with the Redux DevTools extension and it now removes the data field entirely for the query.

I just tried reproducing that and I'm not seeing it happen with the current PR. It's entirely possible my test doesn't match what you were doing. Could you give me a repro?

As I worked on creating a demo of the bug I discovered more details. The bug seems to only happen for queries that take no arguments.

Codesandbox where the issue occurs: https://codesandbox.io/p/sandbox/spring-star-dtc2gy
Another codesandbox with identical code except for the Redux Toolkit version and the removal of the infinte query endpoint, where the issue doesn't occur: https://codesandbox.io/p/sandbox/rtk-2-4-upserquerydata-44fd46

@guaizi149
Copy link

hi, I have been using rtk-query, but now I need this function urgently. Is there a timeline for releasing the beta version for infinite query?

@markerikson
Copy link
Collaborator Author

@guaizi149 : no timeline, but the code in this PR works enough that we're asking people to try it out, let us know if they see bugs, and offer feedback on the API design.

See the top comment for installation instructions (and be sure to actually look in the CodeSandbox CI job for the actual latest commit installation command).

@agusterodin
Copy link

agusterodin commented Dec 11, 2024

Is there any way to access the input parameter provided to the infinite query hook inside of the endpoint definition's query callback function? I can't access the filters I passed in in order to include them in request.

Test.tsx

import { queryEndpoints } from 'api/query'

export default function InfinteQueryTest() {
  const filters = {
    includeOtherUsers: true,
    orderBy: 'id'
  }

  const { } = queryEndpoints.getQueriesCursor.useInfiniteQuery(filters)

  return <div>Chuck</div>
}

query.ts

import { QueryListResponse } from 'datamodels'
import { xplorerApi } from './index'

const queryApiSlice = xplorerApi.injectEndpoints({
  endpoints: builder => ({
    // Generic: <Response type, input type (filters), type that we're using use to keep track of pages>
    getQueriesCursor: builder.infiniteQuery<QueryListResponse, string, number>({
      // We can access variable that keeps track of page, but are unable to access filters because there is no second parameter in callback
      query: (offset, filters) => ({
        url: `queries?offset=${offset}`,
        params: filters
      }),
      infiniteQueryOptions: {
        initialPageParam: 0,
        getPreviousPageParam: (firstPage, allPages, firstPageParam) => {
          if (firstPageParam - 50 > 0) {
            return firstPageParam - 50
          }
        },
        getNextPageParam: (lastPage, allPages, lastPageParam) => {
          if (lastPage.totalResultcount < lastPageParam + 50) {
            return lastPageParam + 50
          }
        }
      }
    })
  })
})

export const { endpoints: queryEndpoints, util: queryUtil } = queryApiSlice

@markerikson
Copy link
Collaborator Author

@agusterodin : you'd have to change the page param type to be {offset: string, page: number}, then update the getNextPageParam callback to actually return that object.

@agusterodin
Copy link

agusterodin commented Dec 12, 2024

@agusterodin : you'd have to change the page param type to be {offset: string, page: number}, then update the getNextPageParam callback to actually return that object.

Oops, my example was screwed up. I meant to have the endpoint generics set up like this:

interface QueryListFilters {
  includeOtherUsers: boolean
  orderBy: 'id' | 'name'
}

const queryApiSlice = xplorerApi.injectEndpoints({
  endpoints: builder => ({
    // Generic: <Response type, input type (filters), type that we're using use to keep track of pages>
    getQueriesCursor: builder.infiniteQuery<QueryListResponse, QueryListFilters, number>({
...

Are you suggesting to do this? I must misunderstand how infinite queries work because this feels like a workaround.

import { QueryListResponse } from 'datamodels'
import { xplorerApi } from './index'

interface QueryListFilters {
  includeOtherUsers?: boolean
  orderBy?: 'id' | 'name'
}

const queryApiSlice = xplorerApi.injectEndpoints({
  endpoints: builder => ({
    getQueriesCursor: builder.infiniteQuery<QueryListResponse, QueryListFilters, { offset: number } & QueryListFilters>({
      query: ({ offset, ...filters }) => ({
        url: `query?offset=${offset}`,
        params: filters
      }),
      infiniteQueryOptions: {
        initialPageParam: { offset: 0 },
        getPreviousPageParam: (firstPage, allPages, firstPageParam) => {
          const { offset, ...filters } = firstPageParam
          if (firstPageParam.offset - 50 > 0) {
            return { offset: offset - 50, filters }
          }
        },
        getNextPageParam: (lastPage, allPages, lastPageParam) => {
          const { offset, ...filters } = lastPageParam
          if (lastPage.totalResultCount < offset + 50) {
            return { offset: offset + 50, ...filters }
          }
        }
      }
    })
  })
})

export const { endpoints: queryEndpoints, util: queryUtil } = queryApiSlice

@markerikson
Copy link
Collaborator Author

@agusterodin : I think that looks right, yeah. Think of it this way: pageParam is essentially what the query arg is for a normal query endpoint, but you need to update that for each different page.

One other nuance here:

There ends up being a separation between the "cache key" value, which is still what you pass to the query hook, and the "initial page param" value. In your case, I think they end up being related - ie, you probably do want to use the filters as the cache key ("all the pages are results for this set of filters"), and then also pass them as the override initial hook value:

// The overall cache entry containing _all_ the pages
// will be keyed by serializing `filters`
const { data } = useMyInfiniteQuery(filters, {
  infiniteQueryOptions: {
    // in order to fetch any given page, we need both `filters` and `offset`
    initialPageParam: {filters, offset: 0}
  }
})

But yeah, questions like this are also why we're asking people to try this out :)

@agusterodin
Copy link

agusterodin commented Dec 13, 2024

Attempting to "port" this Tanstack Query/Virtual infinite scroll example to use RTKQ. Assuming this would be a relatively common use-case.

I got stuck at hasNextPage always being false (looks like this is something on your to-do list).

Not sure if it is helpful, but leaving this here to show how i'm attempting to use the infinite query implementation. Will continue testing against future versions and leave feedback!

InfiniteQueryTest.tsx

import { RefObject, useEffect, useRef } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
import { infiniteQueryEndpoints, QueryListFilters } from 'api/infiniteQuery'
import LoadingSpinner from 'common/components/LoadingSpinner'

const filters: QueryListFilters = {
  all: true,
  orderby: 'id|DESC' as const
}

export default function InfiniteQueryTest() {
  const parentRef = useRef<HTMLDivElement>(null)

  const { allQueries, infiniteQuery, rowVirtualizer } = useInfiniteVirtualizedQueryList(parentRef)
  const { isLoading, isError, hasNextPage } = infiniteQuery

  if (isLoading) {
    return <div>Loading...</div>
  }

  if (isError) {
    return <div>Could not load data.</div>
  }

  return (
    <div className="p-4">
      <div ref={parentRef} className="h-96 w-full max-w-full overflow-auto rounded-lg border border-gray-400 bg-white">
        <div
          className="relative w-full"
          style={{
            height: `${rowVirtualizer.getTotalSize()}px`
          }}
        >
          {rowVirtualizer.getVirtualItems().map(virtualRow => {
            const isLoaderRow = virtualRow.index > allQueries.length - 1
            const query = allQueries[virtualRow.index]

            return (
              <div
                key={virtualRow.index}
                className="absolute left-0 top-0 w-full"
                style={{
                  height: `${virtualRow.size}px`,
                  transform: `translateY(${virtualRow.start}px)`
                }}
              >
                {isLoaderRow && hasNextPage ? <LoadingSpinner className="h-7 w-7 text-teal-500" /> : <div className="px-2">{query.name}</div>}
              </div>
            )
          })}
        </div>
      </div>
    </div>
  )
}

function useInfiniteVirtualizedQueryList<T extends HTMLElement>(parentRef: RefObject<T>) {
  const infiniteQuery = infiniteQueryEndpoints.getQueriesCursor.useInfiniteQuery(filters, {
    pollingInterval: 100000,
    initialPageParam: { offset: 0, ...filters }
  })

  const { hasNextPage, data, fetchNextPage, isFetchingNextPage } = infiniteQuery

  const allQueries = data ? data.pages.flatMap(d => d.queries) : []

  const rowVirtualizer = useVirtualizer({
    count: hasNextPage ? allQueries.length + 1 : allQueries.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 23,
    overscan: 5
  })

  useEffect(() => {
    const [lastItem] = [...rowVirtualizer.getVirtualItems()].reverse()

    if (!lastItem) return

    if (lastItem.index >= allQueries.length - 1 && hasNextPage && !isFetchingNextPage) {
      fetchNextPage()
    }
  }, [hasNextPage, fetchNextPage, allQueries.length, isFetchingNextPage, rowVirtualizer.getVirtualItems()])

  return {
    allQueries,
    infiniteQuery,
    rowVirtualizer
  }
}

@markerikson
Copy link
Collaborator Author

markerikson commented Dec 13, 2024

@agusterodin yeah, if you look at the PR I actually did port that example already :) scratch that, didn't realize you were pointing to TanStack Virtual there - I ported the similar-but-different TanStack Query example.

and yes, we don't have hasNextPage implemented built-in yet, so I had to calculate it while rendering.

@markerikson markerikson force-pushed the feature/infinite-query-integration branch from 0f1db3d to a28de8f Compare December 14, 2024 21:17
@markerikson
Copy link
Collaborator Author

markerikson commented Dec 15, 2024

@agusterodin I just put up a PR implementing all of the infinite query status flags over in #4771 - want to give that a shot?

went ahead and merged in the PR - it also fixed a types error after I rebased this branch, and I feel good about the implementation.

@agusterodin
Copy link

agusterodin commented Dec 19, 2024

Just tried commit 33e30af. Sorry for delayed response.

For some reason the network requests being made to get the "next page" are always the same as the initial request (first page, offset of 0).

Note that the code below is based off of the example you provided under the examples directory (not using Tanstack Virtual).

InfiniteQueryTest.tsx (barely modified from original example). The only noteworthy difference is that I provide initialPageParam to the infinite query hook.filters is intentionally just a hardcoded constant for the time being.

import React from 'react'
import { useInView } from 'react-intersection-observer'
import { infiniteQueryEndpoints, QueryListFilters } from 'api/infiniteQueryApi'

const filters: QueryListFilters = {
  all: true,
  orderby: 'id|DESC' as const
}

export default function InfiniteQueryTest() {
  const { data, error, fetchNextPage, fetchPreviousPage, hasNextPage, isFetchingNextPage, isFetching, isError } =
    infiniteQueryEndpoints.getQueriesCursor.useInfiniteQuery(filters, { initialPageParam: { offset: 0, ...filters } })

  const { ref, inView } = useInView()

  React.useEffect(() => {
    if (inView) {
      console.log('Fetching next page')
      fetchNextPage()
    }
  }, [fetchNextPage, inView])

  return (
    <div className="overflow-auto">
      <h2>Infinite Scroll Example</h2>
      {isFetching ? <p>Loading...</p> : isError ? <span>Error: {error.message}</span> : null}
      {
        <>
          <div>
            <button
              onClick={() => fetchPreviousPage()}
              // disabled={!hasPreviousPage || isFetchingPreviousPage}
            >
              {/* {isFetchingPreviousPage
                ? "Loading more..."
                : hasPreviousPage
                  ? "Load Older"
                  : "Nothing more to load"} */}
            </button>
          </div>
          {data?.pages.map(page => (
            <React.Fragment key={page.queries[0].id}>
              {page.queries.map(query => (
                <p
                  style={{
                    border: '1px solid gray',
                    borderRadius: '5px',
                    padding: '1rem 1rem',
                    background: 'red'
                  }}
                  key={query.id}
                >
                  {query.name}
                </p>
              ))}
            </React.Fragment>
          ))}
          <div>
            <button ref={ref} onClick={() => {
              console.log('Clicked "load newer" button')
              fetchNextPage()
            }} disabled={!hasNextPage || isFetchingNextPage}>
              {isFetchingNextPage ? 'Loading more...' : hasNextPage ? 'Load Newer' : 'Nothing more to load'}
            </button>
          </div>
        </>
      }
    </div>
  )
}

infiniteQueryApi.ts. Our backend doesn't use cursors, we have an offset (starting index of items to return) and limit (amount of items to return).

import { QueryListResponse } from 'datamodels'
import { xplorerApi } from './index'

export interface QueryListFilters {
  all?: boolean
  orderby?: 'id|DESC'
}

const PAGE_SIZE = 50

const queryApiSlice = xplorerApi.injectEndpoints({
  endpoints: builder => ({
    // Generic: <Response type, input type (filters), cache key (page + filters)>
    getQueriesCursor: builder.infiniteQuery<QueryListResponse, QueryListFilters, { offset: number } & QueryListFilters>({
      query: filtersAndOffset => {
        console.log('filters and offset being sent with request', filtersAndOffset)
        return {
          url: 'query/',
          params: { ...filtersAndOffset, limit: PAGE_SIZE }
        }
      },
      infiniteQueryOptions: {
        initialPageParam: { offset: 0 },
        getPreviousPageParam: (firstPage, allPages, firstPageParam) => {
          const { offset, ...filters } = firstPageParam
          if (offset - PAGE_SIZE >= 0) {
            return { ...filters, offset: offset - PAGE_SIZE }
          }
        },
        getNextPageParam: (lastPage, allPages, lastPageParam) => {
          const { offset, ...filters } = lastPageParam
          if (lastPage.count >= offset + PAGE_SIZE) {
            console.log('There is a next page and its offset is: ', offset + PAGE_SIZE)
            return { ...filters, offset: offset + PAGE_SIZE }
          }
        }
      }
    })
  }),
})

export const { endpoints: infiniteQueryEndpoints } = queryApiSlice

Notice from the below screenshot that the getNextPageParam function is getting called prior to when I scroll to the bottom of the page (inView becomes true) or when I manually click the "load newer" button at the bottom of the page. It appears that the offset for the second page is computed correctly inside of my logic ("there is a next page and its offset is: 50" is printed to console), but when the request is made and the query function is called, the value of offset is always 0.

image

Also, another general API question: I have two initialPageParams defined (one in hook and another in endpoint definition). Will this cause conflict? initialPageParams in the endpoint definition was a required field, so I had to put something there despite the fact that I don't think it will ever be used.

PS: sorry that all I have is a shitty screenshot and code snippets. I was contemplating setting up a minimal Vite project, but I wouldn't be able to have it send requests to our backend because it is hitting an authenticated endpoint which makes the setup hard to recreate. I would have attached a video screenshot at the bare minimum, but the MacOS video screenshot tool is apparently broken in the current version of MacOS. If what I provided doesn't just contain a trivial error and isn't adequate to help you identify the issue, I will set something better up.

@markerikson
Copy link
Collaborator Author

@agusterodin yeah, if you could give me a project that shows the inconsistent next page behavior, that would definitely help!

I have two initialPageParams defined (one in hook and another in endpoint definition). Will this cause conflict?

As with the other hook options, providing an option in the hook overrides the default value supplied in the endpoint. Think of it as the general equivalent of a default value for a function argument or destructured object field - if you don't pass it in the hook, we have to have something to fall back to.

@agusterodin
Copy link

Identified issue. If you have the global refetchOnMountOrArgChange: true option set on your API, fetching the next page in infinite queries won't work.

Uncomment line refetchOnMountOrArgChange: true line in lib/notesApi.ts in below repository and the bug will occur.

https://github.com/agusterodin/rtkq-infinite-query-playground

@markerikson markerikson force-pushed the feature/infinite-query-integration branch from 87270fe to 79f6ff8 Compare December 30, 2024 19:35
@markerikson
Copy link
Collaborator Author

@agusterodin Thanks for the repro, that made it pretty easy to identify the cause.

The internal logic had this sequence:

        // Start by looking up the existing InfiniteData value from state,
        // falling back to an empty value if it doesn't exist yet
        const blankData = { pages: [], pageParams: [] }
        const cachedData = getState()[reducerPath].queries[arg.queryCacheKey]
          ?.data as InfiniteData<unknown, unknown> | undefined
        const existingData = (
          isForcedQuery(arg, getState()) || !cachedData ? blankData : cachedData
        ) as InfiniteData<unknown, unknown>

Problem is that isForcedQuery() includes a check against api.refetchOnMountOrArgChange too:

function isForcedQuery(
    arg: QueryThunkArg,
    state: RootState<any, string, ReducerPath>,
  ) {
    const requestState = state[reducerPath]?.queries?.[arg.queryCacheKey]
    const baseFetchOnMountOrArgChange =
      state[reducerPath]?.config.refetchOnMountOrArgChange

    const fulfilledVal = requestState?.fulfilledTimeStamp
    const refetchVal =
      arg.forceRefetch ?? (arg.subscribe && baseFetchOnMountOrArgChange)

    if (refetchVal) {
      // Return if it's true or compare the dates because it must be a number
      return (
        refetchVal === true ||
        (Number(new Date()) - Number(fulfilledVal)) / 1000 >= refetchVal
      )
    }
    return false
  }

So, it was always opting to use blankData instead of the cache data, therefore it it didn't have any existing page params to use, and it never fetches the next page.

I spent a while staring at it, and eventually changed the line to use a different flag we set internally when you actually call refetch(). I'm still not convinced that change is right, but I wrote some new tests that exhibited this broken behavior, and it at least lets those tests pass.

(also I figured out that we have two different refetchOnMountOrArgChange flag definitions. If you define it on the API, it gets used here. If you pass it in to the hooks, it gets used inside of the hook itself, at the React level. Interestingly, we don't currently support overriding that at the endpoint level, which seems like an omission to me, especially since some other config options can be done per-endpoint.)

@markerikson
Copy link
Collaborator Author

@remus-selea added several more examples:

  • cursor-based
  • limit and offset
  • page and size infinite scrolling

@markerikson
Copy link
Collaborator Author

Pretty significant progress today!

  • Fixed types and behavior for the various cache util methods with infinite queries
  • Added types for the auto-generated useSomeInfiniteQuery hooks
  • Exposed refresh from query hooks
  • Added an example with RN FlatList

Also did some checks on bundle size. Looks like the new functionality adds 7K min to all RTKQ usages (but only about 1.5K min+gz).

The bad news is that's non-negotiable and the increase applies even if you never use an infinite query endpoint, due to the changes currently being deeply embedded inside all of the RTKQ core logic.

That said, this PR started with a lot of intentional copy-paste duplication in order to get things working at all. I think I can manage some deduplication and maybe shave off about 2K min of that.

FWIW, it looks like React Query's implementation of infinite queries is about 3K min, so this isn't that far off. Theirs is partially shakeable.

My general intent at this point is that we'll end up shipping roughly the architectural approach that's in this PR, minus whatever deduplication and byteshaving I can do as cleanup before I decide it's ready. I'm always sensitive about bundle size increases, so I certainly wish I had an easy way to make the bundle size cost opt-in and only if you actually have some infinite query endpoints. At the moment, and with the way this PR is currently architected, I don't have a good idea for how to do that.

Longer-term, I've been vaguely tossing around some ideas for seeing if we can pull out some of our functionality based on RTKQ's existing "modules" concept. Currently we just have the "core" and "React" modules. I don't know if it's feasible to make this more shakeable or opt-in, but it's worth investigating. That would happen as part of a future RTK 3.0 effort, no ETA.

All that said, given the amount of actual functionality involved here, 5K min seems like a plausibly reasonable cost to pay to add the feature.

@Abdullah-Nazardeen
Copy link

Hi, RTK Query Team!

I’m currently implementing infinite scroll in my application using RTK Query, and I’ve encountered a challenge related to editing rows and refetching specific data batches.

Here’s the scenario:

Infinite Scroll Setup:

Data is fetched in batches of 100 rows (e.g., rows 0–99, 100–199, etc.).
New data is loaded as the user scrolls down the table.
Editing Rows:

Users can edit individual rows in the table.
For example, if the user edits the 260th row, I want to invalidate and refetch only the batch containing that row (i.e., rows 200–299).
The Challenge:
How can I structure my RTK Query setup to allow invalidating and refetching a specific batch (based on the row index) without refetching all the other batches?

I’ve considered using:

Tags: Associating tags with specific batches (e.g., batch 200–299).
Custom Query Keys: Dynamically generating query keys based on pagination parameters.
However, I’m unsure about the best way to achieve this efficiently while maintaining compatibility with infinite scrolling and avoiding unnecessary refetches.

Additional Context:

I’m using RTK Query’s useQuery for fetching data and invalidateTags for cache management.
My current setup works well for infinite scroll but lacks fine-grained invalidation for specific batches.
Could you provide guidance or suggest best practices for implementing this use case using the new infinite query? Any help would be greatly appreciated!

@markerikson
Copy link
Collaborator Author

@Abdullah-Nazardeen pasted your comment over to a new discussion thread in #4801

@markerikson
Copy link
Collaborator Author

Did a whole of implementation deduplication and byte-shaving.

I estimated I could go from +7K min to +5K min. Currently it looks like this PR adds +4.2K min, +1.4K min+gz, so I've knocked off 2.8K min.

Given the added functionality, I'm going to say that's pretty good :)

markerikson and others added 8 commits January 12, 2025 14:12
* Add blank RTK app

* Nuke initial examples

* Add react-router

* Basic routing

* Add msw

* Configure MSW

* Set up MSW

* Use RTK CSB CI build

* Add baseApi

* Add pagination example

* Add react-intersection-observer

* Add infinite scroll example

* Add max-pages example

* Drop local example lockfile

* Drop back to Vite 5 to fix TS issue

* Align Vite versions to fix test TS error
* Extract InfiniteQueryDirection

* Export page param functions

* Fix useRefs with React 19

* Fix infinite query selector arg type

* Implement infinite query status flags

* Fix types and flags for infinite query hooks

* Add new error messages
…4795)

* Consolidate test assertions

* Add failing tests for infinite queries vs refetching

* Tweak infinite query forced check
* Add bidirectional cursor infinite scroll example

* Add example using limit and offset
- Add example using page and size
- add an intersection observer callback ref hook

* Bump infinite query RTK version

---------

Co-authored-by: Mark Erikson <[email protected]>
* Fix updateQueryData for infinite queries

* Fix upsertQueryData for infinite queries

* Fix upsertQueryEntries for infinite queries

* Fix types in lifecycle middleware
…le (#4798)

* Bump infinite query RTKQ build

* Add rn-web and force infinite query example lockfile

* Bump MSW worker

* Add rn-web types

* Expose refetch from infinite query hooks

* Add RN FlatList example

* Update root lockfile
* Byte-shave infinite query selectors

* Export reusable internal selectors

* Fix selector skiptoken usage

* Use selectors in buildThunks

* Byte-shave cache middleware

* Byte-shave endpoint assignments

* Deduplicate buildThunks

* Deduplicate buildInitiate

* Tweak TransformCallback usage

* Run size checks against the integration branch

* Fix infinite query preselector loading state

* Clarify endpointName

* Deduplicate useQuerySubscription

* Move fetch page functions into subscription hook

* Deduplicate debug values

* Deduplicate useQueryState

* Deduplicate hook unsubs and refetches

* Add test for initial page param references

* Fix cleanup hook issues
@markerikson markerikson force-pushed the feature/infinite-query-integration branch from 91f2cc6 to 2ef6966 Compare January 12, 2025 19:13
@markerikson
Copy link
Collaborator Author

Updated the types to allow providing tags for infinite query endpoints, and added tests to verify that tag invalidation and polling both cause refetches appropriately.

@agusterodin
Copy link

agusterodin commented Jan 13, 2025

Pulled latest commit and have been continuing to try to put together a solid implementation of RTK Query + Tanstack Virtual.

I used the "infinite scroll" example in the React Virtual documentation as a starting point. Note that my example uses the offset + limit pattern, not the "cursor" pattern.

Here is my code so far: https://github.com/agusterodin/rtkq-infinite-query-playground

To start, I abstracted the virtualization and query logic into custom hooks so that the component isn't as cluttered and hard-to-follow. I tested and it behaved as expected.

Next, to make the example more realistic and comprehensive, I added some filters (a debounced searchbox and standard checkbox).


Here are a few issues i'm facing:

Suggested way to resetting virtualizer scroll position when data comes back from a different combination of filters?
Typically, I would set the key attribute on a list's parent element as serialized value of filters to reset scroll position. Unfortunately, the virtualizer handles all scroll position and setting a key has no effect.

This is a solution that kinda works, but feels insanely hacky. I put detailed comments inside the code of what I have tried and potential alternatives. These notes are towards the bottom of app/components/useInfiniteVirtualizedNotesList.ts.

const currentPageParams = infiniteQuery.data?.pageParams?.[0]
const currentPageFilters = currentPageParams ? omit(currentPageParams, 'offset', 'limit') : undefined
const serializedCurrentPageFilters = currentPageFilters ? JSON.stringify(currentPageFilters) : undefined

useEffect(() => {
  rowVirtualizer.scrollToIndex(0)
}, [serializedCurrentPageFilters])

How to show loading spinner when loading new combination of filters? If the first request for the current filters aren't loading, don't show loading spinner, regardless of any fetching that may be happening for old filters (eg: slow request).
What would be the suggested way to show a loading spinner when we are fetching data for a new combination of filters? From what I understand, isLoading is only true when query is currently loading for the first time. Also, isFetching will be true whenever another page is being fetched, even if the filters (excluding offset and limit) are the exact same.

Is using isFetching && !isFetchingNextPage && !isFetchingPreviousPage the recommended solution? As far as I can tell, this doesn't account for the possibility that an additional fetch for a next page of old filters (eg: old slow request) is happening simultaneously while a fetch for page with new filters is also happening.

Changing filters a few times causes more pages to constantly keep loading
You can recreate this by clicking the "show pinned only" checkbox a few times (wait a few seconds before clicking each time). If you have your browser console open you will notice a bunch of request being sent to mock server (requests seem to cascade).

This very well could be my fault. I may have made some sort of mistake when porting the example from Tanstack Query to RTK Query. As far as I could tell, everything was working before I added the additional filters (searchbox and checkbox).

It also could be the way I added the filters, but I don't see anything that jumps out as immediately obvious.


I would be glad to continue keeping you updated as I add to my example. A working example of virtualized infinite scroll with filters and textbox debounce may be really nice to include alongside the release of this feature :)

@markerikson
Copy link
Collaborator Author

markerikson commented Jan 13, 2025

@agusterodin as a reminder, I have no actual experience using infinite queries at all :) which is a major difficulty point trying to implement them.

My main question here would be: how would you implement any of that using React Query instead of RTK Query? And can you point to any apparent differences in behavior between the two? My overall goal is not "guarantee we handle every single hypothetical use case", but rather "match React Query's behavior, API, and known use cases", under the assumption that API design and behavior is good enough for 90%+ of infinite query use cases out there.

Off the top of my head, I don't think we actually expose any field that directly shows "you just changed from arg A to arg B". We do have data vs currentData, which will have different values (keeping the old result's data available until the new result completes), but nothing that has the args themselves. (You obviously have access to the arg you are currently passing in to the query hook, because you wrote that yourself.)

As far as I can tell, this doesn't account for the possibility that an additional fetch for a next page of old filters (eg: old slow request) is happening simultaneously while a fetch for page with new filters is also happening.

Not sure I follow that statement. The flags should be getting derived from the "current" cache entry, which (for a brand new cache entry) will be status: uninitialized, and thus also have the isFetchingNext/PrevPage flags as false.

A working example of virtualized infinite scroll with filters and textbox debounce may be really nice to include alongside the release of this feature :)

Yeah, I've got that infinite query example app in the PR under example/query/react/infinite-queries - whatever you figure out, we can add to that app.

@agusterodin
Copy link

agusterodin commented Jan 13, 2025

@markerikson Roger. I'm in a similar boat.

We currently only have one virtualized infinite scroll list in our application, but plan on using them more in the future as our databases get saturated. The one virtualized infinite scrollable list we have is powered by Redux Saga (only usage of saga across all of our codebases now) and is buggy af.

For 1st issue (reset scroll on filter change): wasn't sure if there is something RTK exposes that would be a better source to know the filter combination of the data that is currently being displayed by the hook. Using infiniteQuery.data?.pageParams?.[0] seems to work and I can likely use https://github.com/kentcdodds/use-deep-compare-effect to ensure stability.

For 3rd issue (unnecessary requests being sent): I don't currently use React Query on any project and have no familiarity, but will attempt to port my example back to React Query and see if it exhibits the same behavior when filters are incorporated. I'll take note of differences in behavior (if any).

I don't want to clutter your PR comments anymore, so unless I find something noteworthy, I will open a GitHub discussion and post there instead.

I really appreciate your hard work on this feature!!

@markerikson
Copy link
Collaborator Author

Thanks, and I seriously appreciate having you actually putting a ton of work into trying this out in real-world situations! Even if I don't have actual answers for your questions, it's incredibly helpful to know that it's actually being tried out by someone else, and the implementation works enough that we're down to these kinds of complicated-but-presumably-rare edge cases.

@david98
Copy link

david98 commented Jan 13, 2025

@markerikson I wanted to let you know that we tried using this new infinite query for an "Instagram-style" infinite feed in our app and, so far, it works way better than the manual merging workaround we were previously adopting :)

@remus-selea
Copy link

@markerikson I've started using tag invalidation to automatically trigger refetches for the infinite query data, and it's been working smoothly. I'm really pleased to have this feature as it allowed me to get rid of a workaround I had in place. I haven’t gotten around to trying out updateQueryData yet, but I’m happy to say that this PR now has everything I needed.

I’m grateful for all the time and effort that’s been put into this!

@DominikBrandys
Copy link

@markerikson I've also replaced old workaround with infiniteQuery and it works great. In my workaround I did not used refetch by tag invalidation in my infinity list. With infiniteQuery it works great. I was really waiting for this. Big thanks to you. Great work.

@markerikson
Copy link
Collaborator Author

Based on the comments here, it sounds like the actual implementation is probably about done code-wise!

I've started work on adding docs for the infinite query feature. WIP draft is here:

@DominikBrandys
Copy link

Just small issue. It seems like there is missing typing for providesTags option for infiniteQuery. It seems that it is typed as never. In my test I bypassed it with ts-ignore. Am I missing something?

@markerikson
Copy link
Collaborator Author

@DominikBrandys good catch :) I merged #4812 last week that fixed those invalid providesTags types to make this work right, but I then did a rebase and apparently dropped that merge commit when I pushed :) Just merged #4821 that re-adds that. The latest PR preview build should work with that now.

@agusterodin
Copy link

agusterodin commented Jan 18, 2025

Have been working on adding filters (a debounced searchbox and simple checkbox) to the Tanstack Query + React Virtual example (from their docs) so I can compare to my test of RTKQ + React Virtual that has filtering functionality. I think i’m noticing some undesirable differences in behavior.

Still trying to narrow down what’s going on before speaking too soon, but will double check my code to make sure no glaring issues on my end, push my changes to reproduction branch, and write something up as soon as possible (Wednesday at latest)

@markerikson
Copy link
Collaborator Author

Just merged in the docs PR. Added API ref coverage for infinite queries in createApi, API ref details for the infinite query hooks, and an "Infinite Queries" usage guide page.

I'd appreciate any feedback on what else should be added to the docs, if anything!

@Dovakeidy
Copy link

@markerikson Maybe it's time to update the comparison table "Infinite scrolling" feature! :)

@ensconced
Copy link
Contributor

ensconced commented Jan 22, 2025

I'm just trying this out in my app - I'll be very pleased to be able to remove our custom solution for this, so thank you!

Just to note that I also came across the issue addressed in this comment. i.e. I have some parameters that I need to use as part of the cache key, and which need to be passed into the request body. These are options for filtering and sorting etc which I wouldn't think of as being part of the pagination params. The solution given works for me but it doesn't feel very natural. In the example above, it feels a bit awkward to have to pass filters twice at the point of calling the hook.

My other question is about flattening the results. The example in the docs just uses .flat() in the component code for this, but is there any way of specifying the flattening logic within the createApi setup? In my case, the API response isn't just an array so the flattening logic is a bit more involved. Previously I was using the merge option for something similar but from what I can tell that's not an option for infinite queries.

@nowaylifer
Copy link

nowaylifer commented Jan 22, 2025

Would be great if queryArg was accessible from queryFn. Right now, data needed to fetch next page and which is the same for all pages has to be duplicated in every page param. For example, I have to pass chatId in every page param in order to fetch the right data:

// api.ts

getChatMessages: build.infiniteQuery<PageData, string, PageParam>({
  infiniteQueryOptions: {
    initialPageParam: { chatId: "", cursor: "", direction: "none" },
    getNextPageParam: ({ endCursor }, _, { chatId }) => {
      return { chatId, cursor: endCursor, direction: "after" };
    },
    getPreviousPageParam: ({ startCursor }, _, { chatId }) => {
      return { chatId, cursor: startCursor, direction: "before" };
    },
  },
  async queryFn(pageParam) {
    const result = await fetchMessages(pageParam.chatId, pageParam);
    return { data: result };
  },
});

// usage.tsx

const chatId = window.location.pathname.slice(1);

const { data } = useGetChatMessagesInfiniteQuery(chatId, {
  initialPageParam: { chatId, cursor: "", direction: "none" },
});

@markerikson
Copy link
Collaborator Author

@ensconced , @nowaylifer : I'll try to put a bit of thought into it, but off the top of my head I'm not sure how we'd be able to change the API design or implementation around passing through the page params. I'll double-check vs how React Query works, but I think you'd have the same issue there.

My other question is about flattening the results. The example in the docs just uses .flat() in the component code for this, but is there any way of specifying the flattening logic within the createApi setup?

Per the initial thread comment, we did discuss adding some kind of a selector definition into the infinite query endpoints, but I decided it wasn't critical enough to hold up the initial release. For now, the simplest approach is to do the restructuring of the data in the component - either directly in rendering, in a useMemo if you feel it's necessary, or optionally with a selectFromResult option in the query hook.

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

Successfully merging this pull request may close these issues.