Implementing infinite scroll with RTK Query #1163
-
Hey all! First off, great work on RTK Query! My team is stoked to start using it. I saw that there isn't built-in support for infinite scrolling yet, but I was wondering if there was any recommended pattern for implementing this ourselves in the meantime. I was thinking we could build a custom hook that wraps useQuery and aggregates the results into an array in local state, but I'm unsure about the specifics. function useInfiniteContactSearchQuery({ query, page }: { query: string; page: number ) {
const [results, setResults] = useState<Array<Contact>>([]);
const result = useContactSearchQuery({ query: query, page: page });
// ...somehow append result.data to results when we fetch a new page here
} Or is there a better way to handle this within Edit: Sorry, just found this discussion which answers my question: https://stackoverflow.com/questions/67909356/is-there-any-way-to-fetch-all-the-responses-stored-in-api-slice-rtk-query |
Beta Was this translation helpful? Give feedback.
Replies: 35 comments 149 replies
-
Status Update, 2024-10-27We're now actively working on a PR to implement official infinite query support! Please see that PR for progress updates, and try it out and give us feedback! Original AnswerCopying my answer from there over here so that people find it more easily 🙂 (also, here would be a generally better place to further discuss any upcoming problems since SO doesn't realle encourage much discussion) I think most implementations overcomplicate the problem of "infinite scroll" by a lot.
Since we are lazy and do not want to track which direction we're scrolling to, just assume
Also let's assume the query returns a response like {
offset: 50,
items: [...]
} So assuming one page is big enough to contain all the data for a screen, we'd end up with something like const currentPage = // something calculated from ScrollPosition
const lastResult = usePageQuery(currentPage - 1, { skip: currentPage === 1 }) // don't fetch pages before 0
const currentResult = usePageQuery(currentPage)
const nextResult = usePageQuery(currentPage + 1)
const combined = useMemo(() => {
const arr = new Array(pageSize * (currentPage + 1))
for (const data of [lastResult.data, currentResult.data, nextResult.data]) {
if (data) {
arr.splice(data.offset, data.items.length, ...data.items)
}
}
return arr
}, [pageSize, currentPage, lastResult.data, currentResult.data, nextResult.data])
// work with `combined` here Since requests that are not referenced by your component, will stay in the store for 60 seconds, the user can scroll up quickly through many pages without further requests - but also, data that will probably never scrolled to again will be removed from the cache and simply re-fetched when necessary. edit from Mark, 2023-02-14 Pasting in Lenz's commentary on why we don't currently ship infinite query support out of the box in RTKQ:
My own thoughts: I think it's clear from this thread that there is a real desire to have some kind of built-in support around infinite queries in RTKQ, and given that it is something I would like us to try to cover at some point. But, given all the other concerns we're juggling right now, it just isn't something we have time to prioritize or focus on atm. At a minimum, it might be nice if we could comb through this page and list some of the specific recipes discussed here in our docs. But again, that requires time and effort and prioritization, and we're very limited on that front. It would be very helpful if folks from the community could pitch in with that. We welcome all attempts at useful contributions, and we'd love to have folks help improve our docs or contribute better functionality! |
Beta Was this translation helpful? Give feedback.
-
I think using cache management utils would be one of a good solution. https://redux-toolkit.js.org/rtk-query/api/created-api/cache-management-utils |
Beta Was this translation helpful? Give feedback.
-
I use that solution with useSelector: import { useEffect, useMemo, useState } from "react";
import { useAppSelector } from "./useStore";
import get from "lodash/get";
const isStringIncludesAllOfArr = (string: string, arr: string[]) =>
arr.every((item: string) => string.includes(item));
export function useCombinedResults<ResultsType>(
endpointName: string | undefined,
includes: string[]
): ResultsType[] {
const [selectorEndpoint, setSelectorEndpoint] = useState("");
useEffect(() => {
if (endpointName) {
setSelectorEndpoint(endpointName);
}
}, [endpointName]);
const queries = useAppSelector((state) => state.handbookApi.queries);
const combinedResults = useMemo(() => {
let results: ResultsType[] = [];
if (selectorEndpoint.length) {
for (const key in queries) {
if (isStringIncludesAllOfArr(key, [...includes, selectorEndpoint])) {
const addarr = get(queries[key], "data.results") || [];
results = [...results, ...addarr];
}
}
}
return results;
}, [selectorEndpoint, includes, queries]);
return combinedResults;
} const { data, error, isLoading, endpointName, originalArgs } =
useGetMyDataListQuery(
{ offset, limit, search },
{ skip: offset > maxOffset }
);
const combinedResults = useCombinedResults<MyDataResultsType>(endpointName, [
`"search":"${search}"`,
]); |
Beta Was this translation helpful? Give feedback.
-
Any plan to support this natively? @phryneas |
Beta Was this translation helpful? Give feedback.
-
What if you want to automatically load all the available pages without user interaction? Example: we have an endpoint that uses pagination, however for 90% of our users pagination is not required. Only our "super users" would need to be able to access all the items. The UI we are using expects to have all the items the user needs available when the component loads. Any ideas? |
Beta Was this translation helpful? Give feedback.
-
MotivationI want to implement one way (top to down) infinite scroll behaviour using RTK Query. ProblemIf I want to refetch all aggregated data on tab focus I will get only last page query. NeedI need a way to change query cache key and originalArgs in the Somewhere in my component: const { data, error, isFetching, isLoading, refetch } =
useGetAllPostsQuery({
limit,
page,
})
// Initiate infinite scrolling behaviour.
const infiniteScrollObserverTarget = useRef<HTMLDivElement | null>(null)
const entry = useIntersectionObserver(infiniteScrollObserverTarget, {})
const targetIsIntersecting = !!entry?.isIntersecting
// Create new query values when we observer target is intersecting.
useEffect(() => {
if (targetIsIntersecting && !isFetching) {
setPage((currentPage) => currentPage + 1)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [targetIsIntersecting, limit, setPage]) And here it is my endpoint definition: endpoints: (builder) => ({
getAllPosts: builder.query<
TGetAllPostsResponse,
TGetAllPostsOptions | void
>({
query: ({
limit = 5,
page = 0,
orderBy = "createdAt",
orderWay = "desc",
} = {}) => {
let queryString = "?"
queryString += `limit=${limit}&`
queryString += `page=${page}&`
queryString += `orderBy=${orderBy}&`
queryString += `orderWay=${orderWay}`
return `posts${queryString}`
},
providesTags: (result) =>
result
? [
...result.data.map(({ id }) => ({
type: "Posts" as const,
id,
})),
]
: [{ type: "Posts", id: "LIST" }],
async onCacheEntryAdded(
arg,
{ cacheDataLoaded, getState, updateCachedData }
) {
// Check if we just not started to build the infinite list.
// You must reset the page after sort order change etc.
if (arg?.page && arg?.limit && arg?.page > 1) {
// wait for the initial query to resolve before proceeding
await cacheDataLoaded
// get key of the last query, the query before the current one
// "getAllPosts({"limit":5,"page":1})"
const lastQueryKeyParams = JSON.stringify({
...arg,
page: arg.page - 1,
})
const lastQueryKey = `getAllPosts(${lastQueryKeyParams})`
const lastCacheEntry = (
getState().api.queries[lastQueryKey]
?.data as TGetAllPostsResponse
).data
// add previous data to the current one
updateCachedData((draft) => {
draft.data.unshift(...(lastCacheEntry as TPostsItem[]))
})
}
},
}),
}), I think that my custom types are irrelevant here in this thread. |
Beta Was this translation helpful? Give feedback.
-
@michalstrzelecki I was able to implement one-way infinite scroll behaviour using RTK Query using a custom Hook. You can check this code file: useInfiniteScroll.ts import { UseQuery, } from '@reduxjs/toolkit/dist/query/react/buildHooks';
import React, { useState, useEffect, useCallback, useMemo } from 'react';
const calculateMaxPages = (total: number, size: number) => {
return Math.ceil(total / size);
};
export const isValidNotEmptyArray = (array: any[]): boolean => {
return !!(array && array?.length && array?.length > 0)
};
export interface IListQueryResponse {
items: any[];
total: number;
page: number;
size: number;
}
const useInfiniteScroll = (useGetDataListQuery: UseQuery<any>, { size = 10, ...queryParameters }) => {
const [localPage, setLocalPage] = useState(1);
const [combinedData, setCombinedData] = useState([]);
const queryResponse = useGetDataListQuery({
page: localPage,
size,
...queryParameters
});
const {
items: fetchData = [],
page: remotePage = 1,
total: remoteTotal = 0,
size: remoteSize = 10
} = queryResponse?.data as IListQueryResponse || {}
useEffect(() => {
if (isValidNotEmptyArray(fetchData)) {
if (localPage === 1) setCombinedData(fetchData);
else if (localPage === remotePage) {
setCombinedData((previousData) => [...previousData, ...fetchData])
}
}
}, [fetchData])
const maxPages = useMemo<number>(() => {
return calculateMaxPages(remoteTotal, remoteSize);
}, [remoteTotal, remoteSize]);
const refresh = useCallback(() => {
setLocalPage(1);
}, []);
const readMore = () => {
if (localPage < maxPages && localPage === remotePage) {
setLocalPage((page) => page + 1);
}
};
return { combinedData, localPage, readMore, refresh, isLoading: queryResponse?.isLoading, isFetching: queryResponse?.isFetching };
};
export default useInfiniteScroll; You can use it on your page in this way. You should use your own custom RTK query service and add its own parameters. I worked perfectly on my react native app. No array repetition and works well when requests last too much time on poor internet connections. const { combinedData, readMore, refresh } = useInfiniteScroll(
useGetProvidersListQuery,
{
latitude: -0.78,
longitude: -78.4,
categoryId: 9
}
); |
Beta Was this translation helpful? Give feedback.
-
Hi.any updates in 2022. I need a best solution for infinity scroll include cache and optimistic updates. Any one know about this Please give me example. RTK Query is best solution all are things except infinity scroll. Pls help. |
Beta Was this translation helpful? Give feedback.
-
Incase anyone is here and wondering how they can implement and Infinite Scroll in With Redux Tookit Query and React Native Flatlist. Since the release of v1.9+ there's a better recommended way of handling infinite scrolling. Skip to the reply #1163 (reply in thread) /// In postsApi.ts
import { createApi } from "@reduxjs/toolkit/query/react";
import { createEntityAdapter } from "@reduxjs/toolkit";
// Create Adapter For Posts To Avoid Duplicates
const postsAdapter = createEntityAdapter({
selectId: (post) => post.id,
sortComparer: (a, b) => b.createdAt - a.createdAt,
});
const postsApi = createApi({
reducerPath: "posts",
endpoints: (build) => ({
fetchPosts: build.query({
keepUnusedDataFor: 600 // Keep unused for longer,
query: (page) => {
return `posts?page=${page}`;
},
transformResponse: (response) => {
// Return Post To State Using Adapters
return postsAdapter.addMany(
postsAdapter.getInitialState({
hasMorePages: response.hasMorePages,
}),
response.data
);
},
async onQueryStarted(page, { queryFulfilled, dispatch }) {
const { data } = await queryFulfilled;
if (data) {
// Merge App Post Into First Page Using Posts Adapter
dispatch(
postsApi.util.updateQueryData(
"fetchPosts",
1,
(draft) => {
postsAdapter.addMany(
draft,
postsSelector.selectAll(data)
);
draft.hasMorePages;
}
)
);
if (page > 1) {
// Remove Cached Data From State If Not Page 1 Since We Already Added It To Page 1
dispatch(
postsApi.util.updateQueryData(
"fetchPosts",
page,
(draft) => {
draft = postsAdapter.getInitialState();
}
)
);
}
}
},
}),
}),
});
export const {
postsApi
} = postsApi;
export default postsApi;
const postsSelectors = postsAdapter.getSelectors((state) => state);
export { postsSelectors, postsAdapter }; On the page you want to render the posts, you can scroll infinitely with an example like this /// PostScreen.ts
import { View, FlatList } from "react-native";
import React, { useEffect, useRef } from "react";
import postsApi, {
postsAdapters,
postsSelectors,
useLazyFetchPostsQuery
} from "../../state/apis/postsApi";
export default function PostsScreen() {
const currentPage = useRef(1);
const [fetchPosts, { isFetching }] =
useLazyFetchPostsQuery();
// Listen For Posts Updates Page 1
const { posts, hasMorePages } =
postsApi.endpoints.fetchPosts.useQueryState(1, {
selectFromResult: (result) => {
return {
hasMorePages: result.data?.hasMorePages,
posts: postsSelectors.selectAll(
result.data ?? postsAdapters.getInitialState()
),
};
},
});
const fetchFirstPage = async () => {
await fetchPosts(currentPage.current);
};
const fetchMorePosts = async () => {
if(!hasMorePages || isFetching) return;
currentPage.current += 1;
await fetchPosts(currentPage.current);
};
useEffect(() => {
// Fetch First Page On Init
fetchFirstPage()
}, [])
return (
<FlatList
data={posts}
renderItem={....}
onEndReached={fetchMorePosts}
/>
);
} For more clarity, I have created an expo snack https://snack.expo.dev/@benqoder/react-native-infinite-scroll-with-rtk-query |
Beta Was this translation helpful? Give feedback.
-
In your line "posts: postsSelectors.selectAll( where does "postsSelectors" come from ? It does give me a error. Thanks |
Beta Was this translation helpful? Give feedback.
-
Please implement this feature, really important! |
Beta Was this translation helpful? Give feedback.
-
Just because we had this come up in chat recently and it is not mentioned here - a very viable solution is also something like function InfiniteScroll() {
const [pageCount, setPageCount] = useState(0)
const pages = [];
for (let page=0; i<pageCount; i++) {
pages.push(<Page page={page} key={page} />)
}
return <>
{pages}
<button onClick={() => setPageCount(c => c+1)}>more</button>
</>
}
function Page(props){
const result = usePageData({page: props.page})
return <>...something...</>
} This can easily be combined with something like https://github.com/bvaughn/react-window to have pages out of the visible screen slowly be removed from cache. |
Beta Was this translation helpful? Give feedback.
-
I am using DynamoDB as the data source, and it returns a
ImplementationThe ideal is to use the very first query as a base collection to accumulate and cache the fetched items: import { SerializedError } from '@reduxjs/toolkit';
import { FetchBaseQueryError } from '@reduxjs/toolkit/dist/query';
import { useAppDispatch } from '@src/redux';
// assume we already have a 'readNoteList' endpoint in noteApi,
// thus we have:
import {
noteApi,
useReadNoteListQuery,
useLazyReadNoteListQuery,
} from '@services/modules/notes';
type Note = { id: string; foo: string; bar: number };
type QueryError = FetchBaseQueryError | SerializedError | undefined;
export interface NoteListParams {
limit?: number;
next?: string;
}
export interface NoteListBody {
notes: Note[];
next?: string; // in my case, it's a LastEvaluatedKey returned from DynamoDB
}
export interface UseInfiniteQueryResult {
// data: { notes: [], next: the latest cursor },
// where data.notes is used to collect and cache the 1st and next fetched notes,
// the field `next` is updated to follow the latest cursor returned from backend (e.g. DynamoDB in my case).
data: NoteListBody;
// for the very first fetching
error?: QueryError;
isError: boolean;
isLoading: boolean;
// for the first fetching and next fetching
isFetching: boolean;
// for next fetches
errorNext: QueryError;
isErrorNext: boolean;
isFetchingNext: boolean;
hasNext: boolean;
fetchNext: () => Promise<void>;
refetch: () => Promise<void>;
}
export function useInfiniteReadNoteListQuery(
params: NoteListParams,
fetchAll: boolean = false // if `true`: auto do next fetches to get all notes at once
) {
const dispatch = useAppDispatch();
// baseResult is for example GET from https://exmaple.com/johndoe/notes
const baseResult = useReadNoteListQuery(params);
// nextResults are for example GET from https://exmaple.com/johndoe/notes?next=latest-cursor-value
// trigger may be fired many times
const [trigger, nextResult] = useLazyReadNoteListQuery();
const isBaseReady = useRef(false);
const isNextDone = useRef(true);
// next: starts with a null, fetching ended with an undefined cursor
const next = useRef<null | string | undefined>(null);
// Base result
useEffect(() => {
next.current = baseResult.data?.next;
if (baseResult.data) {
isBaseReady.current = true;
fetchAll && fetchNext();
}
}, [baseResult]);
// When there comes a next fetched result
useEffect(() => {
if (!nextResult.isSuccess) return;
if (
isBaseReady.current &&
nextResult.data &&
nextResult.data.next !== next.current
) {
next.current = nextResult.data.next; // undefined if no data further
// Put next fetched notes into the first queried collection (as a base collection)
// This can help us do optimistic/pessimistic updates against the base collection
const newItems = nextResult.data.data;
const baseCollection = dispatch(
noteApi.util.updateQueryData(
'readNoteList',
params,
(drafts) => {
drafts.next = nextResult.data.next;
if (newItems && newItems.length > 0) {
// depends on the use case,
// maybe we can do deduplication, removal of some old entries here, if required
// ...
// adding new notes to the cache
drafts.data.push(...newItems);
}
}
)
);
}
}, [nextResult]);
const fetchNext = async () => {
if (
!isBaseReady.current ||
!isNextDone.current ||
next.current === undefined ||
next.current === null
)
return;
try {
isNextDone.current = false;
await trigger({
...params,
next: next.current,
});
} catch (e) {
} finally {
isNextDone.current = true;
fetchAll && fetchNext();
}
};
const refetch = async () => {
isBaseReady.current = false;
next.current = null; // restart
await baseResult.refetch(); // resatrt with a whole new refetching
};
return {
data: baseResult.data,
error: baseResult.error,
isError: baseResult.isError,
isLoading: baseResult.isLoading,
isFetching: baseResult.isFetching || nextResult.isFetching,
errorNext: nextResult.error,
isErrorNext: nextResult.isError,
isFetchingNext: nextResult.isFetching,
hasNext: baseResult.data?.next !== undefined,
fetchNext,
refetch,
};
} Usageexport const FooComponent = (props) => {
// ...
const {
data: NoteListBody, // the base collection that accumulates our notes
error,
isError,
isLoading,
isFetching,
errorNext,
isErrorNext,
isFetchingNext,
hasNext,
fetchNext,
refetch,
} = useInfiniteReadNoteListQuery({
// next: undefined,
// (1) next === undefined: base collection is queried from the beginning of the data source;
// (2) next === 'base_collection_start_from_this_cursor': base collection is queried
// from some point of the data source.
// In most cases, choose (1), leave next undefined.
},
// true // set 2nd arg to true will fetch all notes at once when the first query fired
);
// ...
const onEndReachedFetchNext = () => {
if (hasNext && !isFetching) {
fetchNext();
}
};
return (
<View>
...
<FlatList
data={NoteListBody.data} // the base collection that accumulates our notes
initialNumToRender={10}
scrollEventThrottle={16}
refreshing={refreshing}
onRefresh={() => refetch()}
keyExtractor={keyExtractor}
renderItem={renderSiteItem}
...
onEndReachedThreshold={0.01}
onEndReached={onEndReachedFetchNext}
/>
</View>
);
}; (Optional) Make a helper functionWe can make a helper function to encapsulate the logics: export function createUseInfiniteQuery<TResult, TParams>(api: any, endpointName: string) {
return function (
params: TParams,
fetchAll: boolean = false
) {
// Sorry for not providing the details, I'm still strugging with the typing things.
// There are many `any` types, it's ugly.
}
}; I use the helper function in my // ...
export const {
useCreateNoteMutation,
useReadNoteQuery,
useUpdateNoteMutation,
useDeleteNoteMutation,
useReadNoteListQuery,
useLazyReadNoteListQuery,
} = noteApi;
export const useInfiniteReadNoteListQuery = createUseInfiniteQuery<
ReadNoteListBody,
ReadNoteListParams
>(noteApi, 'readNoteList'); Thanks @davidtkramer and @phryneas for your solutions that inspire me a lot! |
Beta Was this translation helpful? Give feedback.
-
To do this we simply need access to the state in transformResponse!. Guys untie our hands please. |
Beta Was this translation helpful? Give feedback.
-
After examining all the examples and what has been done, I found a method and applied it. I created a state and if the query isFetching false, I found a solution to throw the values in the request into the state. If refreshControl happens in flat list on React native side, I reset my state. Also, there is no problem as I request again with rtk query. In the State, I keep the id key as {id1: {dataObject}} if the value is my value, so if the request is made again in the possible rtk query, the final state will be recorded on it. I wanted to share the solution with you. |
Beta Was this translation helpful? Give feedback.
-
Beta Was this translation helpful? Give feedback.
-
I have created a hook called useInfiniteQuery, which has an API similar to React Query. It is working on my project. I hope that in the following days I will have some time to create a repo.
|
Beta Was this translation helpful? Give feedback.
-
if RTK-query supports global cache client such as react-query 's QueryClient instance, we can fix this issue. |
Beta Was this translation helpful? Give feedback.
-
Hi Team, I have recently started using RTK Query in my RN project. I am currently having a problem to invalidate the cached data after mutation. Below is my problem. I am maintaining page state in my component and incrementing it after onEndReached event fires. I have a notification data fetched from the server. All unread notifications can be marked as Read (individually by tapping on each item). Problem - If the user scrolls to the second page, page state is incremented by 1. Now if the user scrolls up to page 1 and tries to mark the message as read (Mutation), refetching request is done for page 2. Please need your help on this problem. Currently the backend only supports getting all notifications, hence invalidating by individual ID is not possible. |
Beta Was this translation helpful? Give feedback.
-
any plan on supporting this feature out of the box in the next major version? |
Beta Was this translation helpful? Give feedback.
-
Folks, I've gone ahead and created a separate discussion thread in #3174 to get feedback on the different potential use cases people have for doing "infinite query"-type behavior (as shown in Lenz's comment about "pagination vs cursor vs offset" and "full list vs individual fetching"). We'd appreciate if you could submit of use cases in that thread so we can have an idea of the different approaches people need to deal with! |
Beta Was this translation helpful? Give feedback.
-
For anyone who wants to implement infinite query with single cache entry or cursor based pagination, check out my solution and see if it can help you. Prerequisites
interface PaginationArg {
pageSize: number;
cursor: string | null;
}
interface PaginationData<T> {
items: T[];
cursor: string | null;
}
ImplementationTo combine subsequent data in a single cache entry, we can use endpoints: (builder) => ({
getItems: builder.query<PaginationData<Item>, PaginationArg>({
query: (arg) => ({ url: 'items', params: arg }),
merge: (currentData, { items, cursor }) => {
currentData.items.push(...items);
currentData.cursor = cursor;
},
serializeQueryArgs: ({ endpointName }) => {
return endpointName;
},
forceRefetch: ({ currentArg, previousArg }) => {
return !!currentArg?.cursor && currentArg !== previousArg;
},
providesTags: ...
}),
}) But the problem with this approach is when we try to refetch (either by manually or cache invalidation), it doesn't work properly since refetch runs with I fixed this behavior by creating a custom redux middleware which intercepts and modifies actions dispatched by api endpoints. When the query runs, the api dispatches import { Middleware, isPending } from '@reduxjs/toolkit';
import type { AppState } from './store';
export const customMiddleware: Middleware = (api) => (next) => (action) => {
// Exclude unrelated actions
if (!isPending(action) || !action.meta || !action.meta.arg) {
return next(action);
}
const { type, endpointName, originalArgs } = action.meta.arg;
const state = api.getState() as AppState;
const latestQuery = state.api.queries[endpointName];
// Exclude unrelated actions
if (type !== 'query' || !originalArgs || !latestQuery) {
return next(action);
}
const queryArg = originalArgs as PaginationArg;
const latestData = latestQuery.data as PaginationData<any>;
const { items: latestItems, cursor: latestCursor } = latestData;
// Exclude first query runs
if (!latestItems || !latestItems.length || latestCursor === undefined) {
return next(action);
}
// Exclude normal subsequent queries
if (queryArg.cursor === latestCursor && latestCursor !== null) {
return next(action);
}
// Modify originalArgs
action.meta.arg.originalArgs = {
...originalArgs,
pageSize: latestItems.length,
cursor: null,
refetch: true,
} as PaginationArg;
return next(action);
}; This middleware will only modify the action if:
Then we can distinguish the
To make refetch performs as we want, modify Then update merge: (currentData, { items, cursor }, { arg }) => {
if (arg.refetch) {
currentData.items = items;
} else {
currentData.items.push(...items);
}
currentData.cursor = cursor;
}, If your using typescript, you also need to update interface PaginationArg {
pageSize: number;
cursor: string | null;
refetch?: boolean;
} I've tested this approach in my app and it seems working properly. And I think you can also apply this to offset based pagination by comparing current page numbers instead of cursors. |
Beta Was this translation helpful? Give feedback.
-
@markerikson @phryneas Hello guys. export const postApi = rtkQuery.injectEndpoints({
getPostsByUserId: builder.query<GetPostsByUserIdResponse, GetPostsByUserIdRequest>({
query: ({ id, ...params }) => ({
url: `post/user/${id}?${transformObjToUrlParams(params)}`,
}),
serializeQueryArgs: ({ endpointName }) => endpointName,
forceRefetch: ({ currentArg, previousArg }) => currentArg !== previousArg,
merge: (currentCache, newCache, { arg: { offset, resetCache, deleteItem } }) => {
const currentPage = (currentCache.data.length - POSTS_LIMIT) / POSTS_LIMIT;
const nextPage = offset / POSTS_LIMIT;
if (resetCache) {
currentCache.data = newCache.data;
} else if (deleteItem) {
currentCache.data = currentCache.data.filter(item => item.id !== deleteItem);
} else if ((currentPage > 0 && nextPage === currentPage) || nextPage < currentPage) {
currentCache.data.splice(offset, newCache.data.length, ...newCache.data);
} else {
currentCache.data.push(...newCache.data);
}
currentCache.limit = newCache.limit;
currentCache.offset = newCache.offset;
currentCache.count = newCache.count;
},
keepUnusedDataFor: infinity,
}),
updatePost: builder.mutation<UpdatePostResponse, UpdatePostRequest>({
queryFn: (...propsQuery) =>
infiniteUpdaterBaseFn<UpdatePostResponse, typeof propsQuery[0]>({
propsQuery,
options: {
url: `post/${propsQuery[0].id}`,
method: 'PATCH',
rtk: () => postApi,
endpointName: 'getPostsByUserId',
},
}),
}),
})
type AxiosBaseQuery = ReturnType<typeof axiosBaseQuery>;
type InfiniteUpdaterBaseFnProps<T> = {
propsQuery: [
T,
BaseQueryApi,
BaseQueryExtraOptions<AxiosBaseQuery>,
(arg: Parameters<AxiosBaseQuery>[0]) => ReturnType<AxiosBaseQuery>,
];
options: {
url: string;
method: string;
rtk: any;
endpointName: string;
};
};
export const infiniteUpdaterBaseFn = async <T extends AnyObject, P extends InfiniteScrollRequest>({
propsQuery: [data, api, , fetchToBQ],
options: { url, method, rtk, endpointName },
}: InfiniteUpdaterBaseFnProps<P>): Promise<QueryReturnValue<T, BaseQueryError<AxiosBaseQuery>>> => {
const res = await fetchToBQ({
url,
method,
data,
});
const rootState = api.getState() as RootState;
const state = rootState['rtkReducer'];
const prevCacheData = state.queries[endpointName]?.data as { data: T['data'] };
const index = prevCacheData?.data?.findIndex((item: { id: number }) => item.id === data.id) || 0;
api.dispatch(
rtk().endpoints[endpointName].initiate(
{
id: data.refetchData?.userId,
limit: 5,
offset: index,
deleteItem: method === 'DELETE' ? data.id : undefined,
},
{
subscribe: false,
forceRefetch: true,
},
),
);
if (res.error) {
return {
error: res.error,
};
}
return {
data: res.data as T,
};
}; |
Beta Was this translation helpful? Give feedback.
-
I need the infinite scroll possibility on react native, the way they handle data rendering is a bit different compared to the web so the solution proposed by @phryneas wouldn't really work. In this regard I have several questions:
|
Beta Was this translation helpful? Give feedback.
-
Hello, I hade the same requirement to have an infinite scroll in both direction. After reading all the threads on the topic, it seems to me that the most easiest and reliable solution is not to touch RTK query endpoints and try to build on the top of @phryneas initial proposition, to keep delegating caching and invalidating to RTK. // the useInfiniteScroll can take as a props any useQuery generated by RTK
export function useInfiniteScroll<ApiResponseDto extends ResponseBase>(
useGetQuery: UseGetQueryProps<ApiResponseDto>
) {
// getting the current state from redux store.
// You can pass it as a props if you wish or manage it with a useState
const page = useAppSelector((state) => state.filter.page);
const paginatedQueryParams = useAppSelector((state) => state.filter);
const limit = useAppSelector((state) => state.filter.limit);
const dispatch = useAppDispatch();
// this is the original recommandation from @phryneas.
// It ensures that the api store is fully managing the caching, invalidating...
const lastQueryResponse = useGetQuery({
...paginatedQueryParams,
page: page - 1,
});
const currentQueryResponse = useGetQuery(paginatedQueryParams);
const nextQueryResponse = useGetQuery({
...paginatedQueryParams,
page: page + 1,
});
const combinedData = useMemo(() => {
const arr = new Array(limit * (page + 1));
for (const data of [
lastQueryResponse.data,
currentQueryResponse.data,
nextQueryResponse.data,
]) {
if (data) {
const offset = (data.page - 1) * data.limit;
arr.splice(offset, data.data.length, ...data.data);
}
}
const isNotFullyEmpty = arr.some((value) => value !== undefined);
return isNotFullyEmpty ? arr : [];
}, [
currentQueryResponse.data,
lastQueryResponse.data,
limit,
nextQueryResponse.data,
page,
]);
const { page: remotePage = 1, hasNextPage = false } =
currentQueryResponse?.data || {};
const isFetching = useMemo(
() =>
lastQueryResponse?.isFetching ||
currentQueryResponse?.isFetching ||
nextQueryResponse?.isFetching,
[
currentQueryResponse?.isFetching,
lastQueryResponse?.isFetching,
nextQueryResponse?.isFetching,
]
);
// this part of the code is managing the scroll up and down event.
// make sure you are using an intersection observer and not the scroll event.
// it will save you hours of debuging :)
// this part of the code can be abstracted in a useOnScreen custom hook
const lastIntObserver = useRef<IntersectionObserver | null>(null);
const lastRowRef = useCallback(
(node: HTMLDivElement | null) => {
if (isFetching) return;
if (lastIntObserver.current) lastIntObserver.current.disconnect();
lastIntObserver.current = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && hasNextPage && page === remotePage) {
dispatch(updateCurrentPage(page + 1));
}
});
if (node) lastIntObserver.current.observe(node);
},
[dispatch, hasNextPage, isFetching, page, remotePage]
);
const firstIntObserver = useRef<IntersectionObserver | null>(null);
const firstRowRef = useCallback(
(node: HTMLDivElement | null) => {
if (isFetching) return;
if (firstIntObserver.current) firstIntObserver.current.disconnect();
firstIntObserver.current = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && page > 1) {
dispatch(updateCurrentPage(page - 1));
}
});
if (node) firstIntObserver.current.observe(node);
},
[dispatch, isFetching, page]
);
return {
combinedData,
lastRowRef,
firstRowRef,
isFetching,
};
} I hook up firstRowRef to the first row in my table and lastRowRef to the last one. to make everything works together. |
Beta Was this translation helpful? Give feedback.
-
Hint how to do with next_page_token ?
|
Beta Was this translation helpful? Give feedback.
-
Hi. This is my example. I did it with useLazyQuery because I wanted to get rid of waiting for the result. I wanted to get it as soon as I got the result. Also with react-cool-inview, we get rid of the constant scrolling. Hope this helps you :) // useInfiniteScroll.js import { useState, useEffect, useCallback } from 'react';
import { useInView } from 'react-cool-inview'; // https://www.npmjs.com/package/react-cool-inview
import { emptySplitApi } from './emptySplitApi'; // https://redux-toolkit.js.org/rtk-query/usage/code-splitting
const useInfiniteScroll = (endpointName, queryParameters, queryOptions) => {
const [page, setPage] = useState(1);
const [combinedData, setCombinedData] = useState([]);
const [
trigger,
{
data: { data: currentData = [], ...pagination } = {},
isLoading,
isFetching,
},
lastPromiseInfo,
] = emptySplitApi.endpoints[endpointName].useLazyQuery(queryOptions); // https://redux-toolkit.js.org/rtk-query/api/created-api/hooks#uselazyquery
const { observe } = useInView({
threshold: 0.8, // default is 0
// When the last item comes to the viewport
onEnter: ({ unobserve }) => {
// Pause observe when loading data
unobserve();
// Load more data
if (pagination?.next_page_url !== null && !isFetching) {
setPage(page => page + 1);
}
},
}); // https://www.npmjs.com/package/react-cool-inview#infinite-scroll
useEffect(() => {
// Add a subscription
const result = trigger({
page,
limit: 15,
...queryParameters,
})
.unwrap()
.then(data => setCombinedData(previousData => [...previousData, ...(data.data ?? [])]))
.catch(console.error);
// Return the `unsubscribe` callback to be called in the `useEffect` cleanup step
return result.unsubscribe;
// See Option 3. https://github.com/facebook/react/issues/14476#issuecomment-471199055
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [trigger, page, JSON.stringify(queryParameters)]);
const refresh = useCallback(() => {
setPage(1);
}, []);
return {
combinedData,
currentData, // current page data
page,
refresh,
isLoading,
isFetching,
itemRef: observe,
lastArg: lastPromiseInfo.lastArg
};
};
export default useInfiniteScroll; // posts.js import React from 'react';
import useInfiniteScroll from './useInfiniteScroll';
const Posts = () => {
const {
combinedData: posts,
itemRef,
isFetching,
isLoading,
} = useInfiniteScroll('getPosts');
if (isLoading) {
return 'Loading...'
}
return (
<ul>
{posts.map((post, index) => (
<li
key={post.id}
ref={index === posts.length - 1 ? itemRef : null} // If the last item in the viewport, the next page is requested
>
// your code
</li>
))}
</ul>
);
};
export default Posts;
|
Beta Was this translation helpful? Give feedback.
-
2023 and still not a fancy solution for this. Moving to react-query again! |
Beta Was this translation helpful? Give feedback.
-
Hi everyone, I am able to achieve the infinity scroll but it does not work with rtk query tags if a post is deleted or new is added the list should be updated automatically as how tags works. Could anybody provide example how it should work with tags |
Beta Was this translation helpful? Give feedback.
-
As a general FYI for anyone who's still subscribed to this thread: I've finally had time to seriously focus on figuring out how we're going to add official infinite query support to RTK Query, and we've got some good progress! 🎉 Earlier this year, a user submitted a draft PR that tried to implement an equivalent of React Query's public API on top of RTK Query's internals: That PR had sat there untouched, since neither Lenz nor I had time to look at i. In the last couple weeks I've had both the time and energy to prioritize understanding how infinite queries work in general, how React Query's API is designed and implemented, and how this draft PR is implemented and what it actually does thus far. Over the last few days, I've done some significant work on that draft to fix issues with the TS types, add some tests, clean up some of the rough spots in the API design, and improve the functionality. Also, Lenz and I met with Dominik (React Query maintainer), and discussed how their implementation works and why they made certain design decisions. As of right now, the draft PR in #4393 builds and passes some initial tests. I need to add a lot more tests and try it out in some actual meaningful examples, but I think what's there is actually ready for some brave folks to try it out and give us feedback. 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 over in that PR! My current somewhat ambitious goal is to ship a final version of infinite query support for RTKQ by the end of this year. 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! |
Beta Was this translation helpful? Give feedback.
Status Update, 2024-10-27
We're now actively working on a PR to implement official infinite query support!
Please see that PR for progress updates, and try it out and give us feedback!
Original Answer
Copying my answer from there over here so that people find it more easily 🙂 (also, here would be a generally better place to further discuss any upcoming problems since SO doesn't realle encourage much discussion)
I think most implementations overcomplicate the problem of "infinite scroll" by a lot.
You can achieve this by stepping a bit back and thinking about what you really need:
Since we are lazy a…