Polling for new content using react-query useInfiniteQuery

Matt Miller
6 min readApr 5, 2023

--

We build native iOS and Android apps on my team at work using react-native. Among our most heavily used dependencies is react-query (v3). react-query has excellent docs as well as a supplemental official set of blog posts that go through everything you would ever want to know about using the library effectively. It’s pretty straightforward to use and very powerful.

I found myself working on an interesting problem: I wanted to keep a growing cache of data from an API endpoint, and periodically request “fresh” data, only fetching records created since the last time I queried.

For example, consider a REST endpoint that returns an array of activity records. For simplicity’s sake, we’ll assume the endpoint is not paginated. Each record represents some important event that we might want to display in the user’s “inbox.” Events are returned in chronological order.

GET /inbox

[
{
"id": "8a7e0ad0-8261-4074-91a0-c386bf6c2f8c",
"event": "comment",
"description": "Someone commented on your post!",
"date": "2023-04-05T20:16:32.282Z"
},
{
"id": "4a81e2c0-772c-493a-93c4-abeda23475e3",
"event": "like",
"description": "Someone liked your post!",
"date": "2023-04-05T22:22:59.003Z"
},
{
"id": "26f2d8b9-59d7-4cb8-98cc-f4b235583841",
"event": "post",
"description": "Someone you follow posted!",
"date": "2023-04-05T22:30:17.803Z"
}
]

While our endpoint does not paginate, it does offer the ability to filter results via query parameters. Specifically, it allows us to filter to only results that occur after a given timestamp.

GET /inbox?after=2023–04–05T22:29:19.534Z

[
{
"id": "26f2d8b9-59d7-4cb8-98cc-f4b235583841",
"event": "post",
"description": "Someone you follow posted!",
"date": "2023-04-05T22:30:17.803Z"
}
]

Given that our theoretical endpoint doesn’t paginate the results, we don’t need to use an infinite query for this purpose — a regular query will suffice:

type FetchParams = {
after?: string | undefined;
};

type FetchResponse = InboxEvent[];

type InboxEvent = {
id: string;
event: string;
description: string;
date: string;
};

async function fetchInbox(params: FetchParams): Promise<FetchResponse> {
const qs = params.after
? `?after=${encodeURIComponent(params.after)}`
: "";

const response = await fetch(`/inbox${qs}`);
return await response.json();
}

type InboxQueryKey = ["inbox", FetchParams];

function getInboxQueryKey(params: FetchParams): InboxQueryKey {
return ["inbox", params];
}

function useInboxQuery(params: FetchParams) {
return useQuery({
queryKey: getInboxQueryKey(params),
queryFn: async () => await fetchInbox(params),
});
}

But I got to thinking:

I want to be able to poll this endpoint for updates without downloading all the history again. How could I continue to poll for new activity while the app is running, merging the data I already had in cache? This would be especially valuable since we use the experimental persistQueryClient and createAsyncStoragePersistor APIs to cache certain network responses on the device for speedier performance.

The solution? Treat the problem like a pagination problem: instead of specifying a page parameter, we will simply use after as our “pagination” parameter. After all, react-query’s useInfiniteQuery is intentionally designed to allow any value to serve as its pageParam.

Now that we are using the after parameter as a pagination parameter, we no longer include it in our query key. The query data for an infinite query all lives under a single query key.

type InboxQueryKey = ["inbox"];
const inboxQueryKey: InboxQueryKey = ["inbox"];

function useInboxQuery() {
return useInfiniteQuery({
queryKey: inboxQueryKey,
queryFn: async ({ pageParam = "" }) => {
return await fetchInbox({ after: pageParam });
},
getNextPageParam: (_lastPage, allPages) => {
// after should be the date of the most recent record we have
const lastPage = allPages.findLast(
(page) => page.length > 0,
);

const lastRecord = lastPage[lastPage.length - 1];
return lastRecord?.date ?? "";
},
staleTime: Infinity, // prevent re-fetching old data
});
}

When this query is first fetched, the “first page” is requested. pageParam is initially set to undefined by react-query, so as a result, we make a request to the API to fetch all records with no filtering.

If we call query.fetchNextPage() then getNextPageParam will be used to determine what parameters we should pass to our next API call.

In this case, we want to fetch all records newer than the newest record we have on hand. The getNextPageParam callback takes allPages as its second parameter; we can use the array of pages to find our newest record pretty quickly. Our goal is for each “page” to contain the activity that occurred after the activity of the previous page. Since our API returns the events in chronological order, the newest record we have will be the last record on the last page we have. However, since it’s possible that a request could return an empty list of events, we have to make sure that we define “last page” as the last non-empty page in order to find the most recent record we have.

Once we find the most recent inbox event, we simply use its timestamp as the parameter to the next API request.

The results will be accumulated in the query data. Infinite queries are stored in a particular structure:

type InboxQueryData = {
pages: FetchResponse[];
pageParams: [null, ...string[]];
};

The results of each request are stored in an array called pages. Since our API response was an array, this means pages is an array of arrays, each nested array being an InboxEvent.

pageParams is an array of the same length as pages. The first element is null, for the initial request for the first page of results — correspondingly, the first element of pages is our unfiltered initial request. The other items are whatever our getNextPageParam() function returned.

If our getNextPageParam() function ever returns undefined, it signals to react-query that no more pages are available. We never want this to happen, so we make sure to always send the timestamp of the most recent event we have, so we can request more.

Our imaginary timeline follows a user’s experience as the app is in the foreground over a period of several minutes. Let’s say that the user opens the app at 22:03, 3 minutes after the most event in their inbox. We’ll call query.fetchNextPage() every 2 minutes while the app is in the foreground. In other words, we will request a new page of data every odd minute in the timeline.

  1. 22:03 | The user opens the app. The “first page” is requested (GET /inbox) and all events are loaded. At this time, the most recent event occurred at 10:00.
    { pages: [page1], pageParams: [null] }
  2. 22:04 | The user’s friend adds a comment to the user’s post.
  3. 22:05 | query.fetchNextPage() is called. The API is called with the timestamp of the most recent event we have, 22:00 (GET /inbox?after=2023–04–05T22:00:00.000Z). The new event that occurred at 10:04 is returned from the endpoint and becomes “page 2” of our data.
    { pages: [page1, page2], pageParams: [null, "...T22:00:00.000Z"] }
  4. 22:07 | query.fetchNextPage() is called again, this time with our new most recent event’s timestamp, which is 10:04. Nothing new has occurred, so the API returns an empty array. The empty array becomes “page 3” of our data.
    { pages: [page1, page2, page3], pageParams: [null, "...T22:00:00.000Z", "...T22:04:00.000Z"] }
  5. 22:08 | One of the people the user is following posts something.
  6. 22:09 | query.fetchNextPage() is called again. The last request returned an empty set, so our most recent event’s timestamp is still 10:04. Therefore, our getNextPageParam callback will return the same timestamp as the last time query.fetchNextPage() was called. However, react-query will still issue another request with the same param. So the API is called again with after set to 10:04. “Page 4” of our data contains the new post event that occurred at 10:08.
    { pages: [page1, page2, page3, page4], pageParams: [null, "...T22:00:00.000Z", "...T22:04:00.000Z", "...T22:04:00.000Z"] }

We can iterate over our “pages” easily to get all the activity as a single list, e.g. query.data?.pages.flat() . Or, we could leverage the select option from react-query, though there are some TypeScript definition bugs related to infinite queries and select.

type UseInboxQueryResult =
| (
(
| InfiniteQueryObserverIdleResult<InboxEvent>
| InfiniteQueryObserverLoadingResult<InboxEvent>
| InfiniteQueryObserverLoadingErrorResult<InboxEvent>
) & {
flatData: undefined;
}
)
| (
(
| InfiniteQueryObserverRefetchErrorResult<InboxEvent>
| InfiniteQueryObserverSuccessResult<InboxEvent>
) & {
flatData: InboxEvent[];
}
);

function useInboxQuery(): UseInboxQueryResult {
const query = useInfiniteQuery({
queryKey: inboxQueryKey,
queryFn: async ({ pageParam = "" }) => {
return await fetchInbox({ after: pageParam });
},
getNextPageParam: (_lastPage, allPages) => {
const lastPage = allPages.findLast(
(page) => page.length > 0,
);

const lastRecord = lastPage[lastPage.length - 1];
return lastRecord?.date ?? "";
},
staleTime: Infinity,
});

const flatData = useMemo<InboxEvent[] | undefined>(() => {
return query.data?.pages.flat();
}, [query.data]);

return { ...query, flatData };
}

Finally, with a dedicated singleton hook, we can subscribe to updates by calling query.fetchNextPage() on an interval. The pause and resume functions can be used to relinquish control of the subscription to respond to events like the user backgrounding the app.

type UsePollInboxEffectResult = {
pause: () => void;
resume: () => void;
};

const POLL_TIME = 2 * 60 * 60 * 1000; // 2 minutes

function usePollInboxEffect(): UsePollInboxEffectResult {
const query = useInboxQuery();
const [paused, setPaused] = useState(false);

useEffect(() => {
if (paused) {
return;
}

const intervalId = setInterval(() => {
query.fetchNextPage();
}, POLL_TIME);

return () => {
clearInterval(intervalId);
};
}, [query.fetchNextPage, paused]);

return useMemo(() => ({
pause: () => setPaused(true),
resume: () => setPaused(false),
}), []);
}

And there you have it! react-query is quite a powerful tool that you can twist to suit almost any use case.

Check out this example on CodeSandbox to see the process in action:

Sign up to discover human stories that deepen your understanding of the world.

Free

Distraction-free reading. No ads.

Organize your knowledge with lists and highlights.

Tell your story. Find your audience.

Membership

Read member-only stories

Support writers you read most

Earn money for your writing

Listen to audio narrations

Read offline with the Medium app

--

--

Matt Miller
Matt Miller

No responses yet

Write a response