Hi Tanner!
I've found that integration React Query with backend paginated APIs ("prev/next page", not load more/infinite scroll) is pretty difficult.
There's an example in readme, in which query variables are used for that:
function Todos() {
const [page, setPage] = useState(0)
const { data, isLoading, error } = useQuery(
['todos', { page }],
fetchTodoList
)
const onNextPage = () => {
setPage(page => page + 1)
}
return (
<>
{/* ... */}
<button onClick={onNextPage}>Load next page</button>
</>
)
}
But i'm not sure, that this is the proper way to handle that.
page - a new query is created, so data is null. This leads to blinking, since data from previous page aren't available yet, so UX experience is poor in this caseimport React from "react";
import fetch from "../lib/fetch";
import { useQuery } from "react-query";
function Component({ initialData }) {
const [page, setPage] = React.useState(1);
const { data } = useQuery(
["data", { page }],
async () => await fetch(`/api/data?page=${page}`),
{ initialData }
);
// ...
}
Component.getInitialProps = async () => {
const data = await fetch('/api/data?page=1');
return { initialData: data };
};
When page changes, new query is created and it's data is set to initialData.
In this case user sees initialData before new query data is fetched:

Here's reproducible example: https://codesandbox.io/s/react-query-pagination-with-ssr-03tzu
I've managed to workaround both issues by:
page as query variablerefetchQuery every time page changesfunction Component({ initialData }) {
const [page, setPage] = React.useState(1);
const { data } = useQuery(
"data",
async () => await fetch(`/api/data?page=${page}`),
{ initialData }
);
React.useEffect(() => {
refetchQuery('data');
}, [page]);
// ...
}
This did the trick, but:
What is you experience in integration React Query with backend pagination and Is there idiomatic way to it?
Will be happy to discuss!
Update: I've came up with much better solution:
function usePageQuery(queryKey, queryFn, { initialData, ...queryConfig } = {}) {
const initialDataRef = React.useRef(initialData);
const query = useQuery(queryKey, queryFn, {
...queryConfig,
initialData: initialDataRef.current,
});
initialDataRef.current = query.data;
return query;
}
Basically, I'm using results of previous page as initial data of next page.
This solves all issues listed above, and API of usePageQuery is identical to useQuery.
I'm thinking about how to integrate that into React Query and following options come to my mind:
useQuery, which will do the same usePageQuery does, but within same query key string.usePageQuery hook to react-query package. Although the name should be more generic (something like usePrefilledQuery?)@tannerlinsley what do you think?
after changing the page - a new query is created, so data is null. This leads to blinking, since data from previous page aren't available yet, so UX experience is poor in this case
I have a similar use case where I'm displaying a list of paginated data (and not using the paginated: true option in useQuery). So when switching between pages I just display a loading indicator
const MyComp = () => {
const [page, setPage] = useState(0)
const {data, error} = useQuery(['key', {page}], ({page}) => ...)
if (error) {
return <NotFound />
} else if (!data) {
return <Loading />
} else {
return ... // Handle rendering your data
}
}
Otherwise I'd suggest using the paginated: true option and then splitting the returned pages into whatever size you want on the client side
Hey @JaceHensley
Thanks for your comment!
Otherwise I'd suggest using the paginated: true option and then splitting the returned pages into whatever size you want on the client side
Indeed, I've considered paginated option for this use case, but it seems like overkill to me.
Especially, when user returns to paginated list and all pages are fetching again.
Assuming, that user returns to non-first page, fetching all previous pages doesn't make a lot of sense, since user sees only one page at a time.
Yeah that's true, that's kinda why I do it the way in my first comment
@JaceHensley that's also what I did at first, but as I mentioned above there are 2 issues with that approach:
initialData when using SSROh so it seems like the data fallsback to config.initialData in between other queries, I didn't realize that at first. That I'm not sure sure what's expected to happen but could you only set config.initialData when page === 0?
import React from "react";
import fetch from "../lib/fetch";
import { useQuery } from "react-query";
function Component({ initialData }) {
const [page, setPage] = React.useState(1);
const { data } = useQuery(
["data", { page }],
async () => await fetch(`/api/data?page=${page}`),
{ initialData: page === 1 ? initialData : undefined }
);
// ...
}
Component.getInitialProps = async () => {
const data = await fetch('/api/data?page=1');
return { initialData: data };
};
For the jumpy UI I'm not really sure but it seems like something that should be handled by the app leveraging the isLoading/error values given from useQuery since there will always be some sort of lag when making a request to the server. Like in my example above, in the actual app I have the loading indicator wrapped in a div that sets the height to be about what I expect the actual content to take up, this helps reduce the jumpiness for me.
Also that usePageQuery hook looks like it's just storing a previous value of data so something like this might be better.
const MyComp = ({initialData}) => {
const { data } = useQuery(
["data", { page }],
async () => await fetch(`/api/data?page=${page}`),
{initialData}
);
const prevDataRef = usePrevious(data)
const dataToUse = data || prevDataRef.current
// Use `dataToUse` when mapping/displaying data
}
But I don't know if I'd necessarily use that pattern cause what if one request where page === 2 fails? Now you pagination UI says it's on page 2 but data from page 1 is being displayed. To handle that you could handle when error is not null and display a message to the user. But now what if a request takes a long time? The pagination UI says it's on page 2 but data is again being displayed for page 1, and the user doesn't know that a request has been fired off or maybe they think the app is being unresponsive or sluggish.
These are just my opinions on the matter and by no means an authoritative opinion :)
This is an interesting situation. My first thought is that what you're referring to in some way is what concurrent mode and suspense do for us. You would essentially be able to hit "Next Page", see a loading indicator in the button, and still see the old page until everything is ready. But I agree, not everyone can move to suspense. That is where I believe your solution from https://github.com/tannerlinsley/react-query/issues/78#issuecomment-565450676 seems to be perfectly viable, but only assuming you are handling error states properly. Maybe something like this:
function useQuery(
queryKey,
queryFn,
{ initialData, useLastResultAsInitialData, onSuccess, ...queryConfig } = {}
) {
const initialDataRef = React.useRef(initialData)
return useQuery(queryKey, queryFn, {
...queryConfig,
initialData: useLastResultAsInitialData
? initialDataRef.current
: initialData,
onSuccess: useLastResultAsInitialData
? data => {
initialDataRef.current = query.data
if (onSuccess) {
return onSuccess(data)
}
}
: onSuccess,
})
}
function App() {
const [page, setPage] = React.useState(0)
const { refetch } = usePageQuery(['todos'], () => getTodos({ page }), {
useLastResultAsInitialData: true,
})
React.useEffect(() => {
refetch()
}, [refetch])
}
Either way, I see this as more of an issue with non suspenseful react than React Query at this point, mostly because there isn't a one-size-fits-all solution here. Let me know what you think of the above solution and how much convenience/pain something like that would give you and we can discuss more.
@tannerlinsley this is a good idea and seems to work. My concern is what happens if the next query that is going to be performed is not of the same kind (not the same queryKey)?
The initialData for this new page query are going to be the same as the previous one. Right?
Maybe something like this would catch this case as well
not tested!
```javascript
function usePageQuery(
queryKey,
queryFn,
{ initialData, useLastDataOfQuery, onSuccess, ...queryConfig } = {}
) {
const initialDataRef = React.useRef(initialData)
const initialLastDataOfQuery = React.useRef(useLastDataOfQuery)
const useLastResultAsInitialData = initialLastDataOfQuery.current === useLastDataOfQuery
return useQuery(queryKey, queryFn, {
...queryConfig,
initialData: useLastResultAsInitialData
? initialDataRef.current
: initialData,
onSuccess: useLastResultAsInitialData
? data => {
initialDataRef.current = data
if (onSuccess) {
return onSuccess(data)
}
}
: onSuccess,
})
}
function App() {
const [page, setPage] = React.useState(0)
const { refetch } = usePageQuery(['todos'], () => getTodos({ page }), {
useLastDataOfQuery: "todos",
})
React.useEffect(() => {
refetch()
}, [refetch])
}
@tannerlinsley Thanks for looking into this!
What do you think about incorporating this hook into React Query?
I'm trying to prevent developers wasting time on solving this issue, which might be pretty common.
EDIT: Meanwhile, I've published react-query-pages package.
Regarding handling error states - I will try it out, thanks for suggestion!
@tannerlinsley I am checking what happens if first page is loaded, but next page couldn't be fetched,
In this case data from previous page are returned, even though there's an error.
And it's actually not related to pagination at all - same happens using pure useQuery.
I'm wondering if data should be set to null, when query failed to fetch?
Huzzah! V1 of React Query is going to nail this pattern. Trust me, you'll love it. Thanks for all of your input everyone!
@tannerlinsley Thanks Tanner, looking forward to checking it out!
Most helpful comment
Huzzah! V1 of React Query is going to nail this pattern. Trust me, you'll love it. Thanks for all of your input everyone!