Note that it's a demo. The data is mocked and not persisted. Live mode might take a while to load new data.
While TanStack provides excellent documentation on Infinite Queries, this article offers an additional practical example focusing on implementing a live data update.
Basic Concept
Infinite queries work with "pages" of data. Each time you load new data, a new "page" is either appended (load more older data) or prepended (live mode newer data) to the data.pages array defined by the useInfiniteQuery hook. In the documentation, you'll read lastPage and firstPage to refer to the last and first page respectively.
Each query to our API endpoint requires two key parameters:
A cursor - a pointer indicating a position in the dataset
A direction - specifying whether to fetch data before or after the cursor ("prev" or "next")
A timeline sketch of the infinite query behavior:
The nextCursor is the timestamp of the last item of the page. The "next" page will only fetch items that are older than the nextCursor.
The prevCursor is the timestamp of the first item of the page (or the current timestamp). The "prev" page will only fetch items that are newer than the prevCursor.
API Endpoint
Your API endpoint should return at minimum:
typeReturnType<T> = {// The single "page" data to be rendereddata:T[];// The timestamp to be used for the next page on _load more_nextCursor?:number|null;// The timestamp to be used for the previous page on _live mode_prevCursor?:number|null;};
typeReturnType<T> = {// The single "page" data to be rendereddata:T[];// The timestamp to be used for the next page on _load more_nextCursor?:number|null;// The timestamp to be used for the previous page on _live mode_prevCursor?:number|null;};
When fetching older pages ("next" direction), we set a LIMIT clause (e.g., 40 items). However, when fetching newer data ("prev" direction), we return all data between the prevCursor and Date.now().
Let's take a look at an example implementation of the API endpoint:
// import ...typeTData= {id:string;timestamp:number;// ...};exportasyncfunctionGET(req:NextRequest) {constsearchParams= request.nextUrl.searchParams;constcursor= searchParams.get("cursor");constdirection= searchParams.get("direction");// Live modeif (direction ==="prev") {constprevCursor= Date.now();constdata=awaitsql` SELECT * FROM table WHERE timestamp > ${cursor} AND timestamp <= ${prevCursor} ORDER BY timestamp DESC `;constres:ReturnType<TData> = { data, prevCursor, nextCursor: null };return Response.json(res);// Load more } else {constdata=awaitsql` SELECT * FROM table WHERE timestamp < ${cursor} ORDER BY timestamp DESC LIMIT 40 `;constnextCursor= data.length>0? data[data.length-1].timestamp :null;constres:ReturnType<TData> = { data, nextCursor, prevCursor: null };return Response.json(res); }}
// import ...typeTData= {id:string;timestamp:number;// ...};exportasyncfunctionGET(req:NextRequest) {constsearchParams= request.nextUrl.searchParams;constcursor= searchParams.get("cursor");constdirection= searchParams.get("direction");// Live modeif (direction ==="prev") {constprevCursor= Date.now();constdata=awaitsql` SELECT * FROM table WHERE timestamp > ${cursor} AND timestamp <= ${prevCursor} ORDER BY timestamp DESC `;constres:ReturnType<TData> = { data, prevCursor, nextCursor: null };return Response.json(res);// Load more } else {constdata=awaitsql` SELECT * FROM table WHERE timestamp < ${cursor} ORDER BY timestamp DESC LIMIT 40 `;constnextCursor= data.length>0? data[data.length-1].timestamp :null;constres:ReturnType<TData> = { data, nextCursor, prevCursor: null };return Response.json(res); }}
Key points:
Live mode ("prev" direction): Returns all new data between Date.now() and the cursor from the first page
Load more ("next" direction): Returns 40 items before the cursor of the last page and updates nextCursor
Important: Be careful with timestamp boundaries. If items share the same timestamp, you might miss data because of the > comparison. To prevent data loss, include all items sharing the same timestamp as the last item in your query.
Avoid Using OFFSET with Frequent Data Updates in Non-Live Mode
While it might be tempting to use the cursor as an OFFSET for pagination (e.g. ?cursor=1, ?cursor=2, ...), the following approach can cause problems when data is frequently prepended:
constlimit=40;constoffset= limit * cursor;constdata=awaitsql` SELECT * FROM table ORDER BY timestamp DESC LIMIT ${limit} OFFSET ${offset}`;
constlimit=40;constoffset= limit * cursor;constdata=awaitsql` SELECT * FROM table ORDER BY timestamp DESC LIMIT ${limit} OFFSET ${offset}`;
When new items are prepended, they shift the offset values, causing duplicate items in subsequent queries.
Client Implementation
Let's call our API endpoint from the client and use the dedicated infinite query functions that are added to the useQuery hook.
"use client";import React from"react";import { useInfiniteQuery } from"@tanstack/react-query";constdataOptions= { queryKey: ["my-key",// any other keys, e.g. for search params filters ],queryFn: async ({ pageParam }) => {const { cursor, direction } = pageParam;constres=awaitfetch(`/api/get/data?cursor=${cursor}&direction=${direction}`, );constjson=await res.json();// For direction "next": { data: [...], nextCursor: 1741526294, prevCursor: null }// For direction "prev": { data: [...], nextCursor: null, prevCursor: 1741526295 }return json asReturnType; },// Initialize with current timestamp and get the most recent data in the past initialPageParam: { cursor: newDate().getTime(), direction: "next" },// Function to fetch newer datagetPreviousPageParam: (firstPage, allPages) => {if (!firstPage.prevCursor) returnnull;return { cursor: firstPage.prevCursor, direction: "prev" }; },// Function to fetch older datagetNextPageParam: (lastPage, allPages) => {if (!lastPage.nextCursor) returnnull;return { cursor: lastPage.nextCursor, direction: "next" }; },};exportfunctionComponent() {const { data, fetchNextPage, fetchPreviousPage } =useInfiniteQuery(dataOptions);constflatData= React.useMemo( () => data?.pages?.flatMap((page) => page.data ?? []) ?? [], [data?.pages], );return <div>{flatData.map((item) => {/* render item */})}</div>;}
"use client";import React from"react";import { useInfiniteQuery } from"@tanstack/react-query";constdataOptions= { queryKey: ["my-key",// any other keys, e.g. for search params filters ],queryFn: async ({ pageParam }) => {const { cursor, direction } = pageParam;constres=awaitfetch(`/api/get/data?cursor=${cursor}&direction=${direction}`, );constjson=await res.json();// For direction "next": { data: [...], nextCursor: 1741526294, prevCursor: null }// For direction "prev": { data: [...], nextCursor: null, prevCursor: 1741526295 }return json asReturnType; },// Initialize with current timestamp and get the most recent data in the past initialPageParam: { cursor: newDate().getTime(), direction: "next" },// Function to fetch newer datagetPreviousPageParam: (firstPage, allPages) => {if (!firstPage.prevCursor) returnnull;return { cursor: firstPage.prevCursor, direction: "prev" }; },// Function to fetch older datagetNextPageParam: (lastPage, allPages) => {if (!lastPage.nextCursor) returnnull;return { cursor: lastPage.nextCursor, direction: "next" }; },};exportfunctionComponent() {const { data, fetchNextPage, fetchPreviousPage } =useInfiniteQuery(dataOptions);constflatData= React.useMemo( () => data?.pages?.flatMap((page) => page.data ?? []) ?? [], [data?.pages], );return <div>{flatData.map((item) => {/* render item */})}</div>;}
The getPreviousPageParam and getNextPageParam functions receive the first and last pages respectively as their first parameter. This allows us to access the return values from the API, prevCursor and nextCursor and to track our position of the cursor in the dataset.
TanStack provides helpful states like isFetchingNextPage and isFetchingPreviousPage for loading indicators, as well as hasNextPage and hasPreviousPage to check for available pages - especially useful for as we can hit the end of the load more values. Check out the useInfiniteQuery docs for more details.
Both fetchNextPage and fetchPreviousPage can run independently and in parallel. TanStack Query manages appending and prepending pages to the data.pages array accordingly.
Implementing Auto-Refresh
While TanStack Query provides a refetchInterval option, it would refetch all pages, growing increasingly expensive as more pages are loaded. Additionally, it doesn't reflect the purpose of live mode as instead of refreshing the data, we want to fetch newer data.
Therefore, we implement a custom refresh mechanism for fetching only new data that you can add to any client component. Here's an simple example implementation of the LiveModeButton:
"use client";import*as React from"react";importtype { FetchPreviousPageOptions } from"@tanstack/react-query";constREFRESH_INTERVAL=5_000; // 5 secondsinterfaceLiveModeButtonProps {fetchPreviousPage?: (options?:FetchPreviousPageOptions|undefined, ) =>Promise<unknown>;}exportfunctionLiveModeButton({ fetchPreviousPage }:LiveModeButtonProps) {// or nuqs [isLive, setIsLive] = useQueryState("live", parseAsBoolean)const [isLive, setIsLive] = React.useState(false); React.useEffect(() => {let timeoutId:NodeJS.Timeout;asyncfunctionfetchData() {if (isLive) {awaitfetchPreviousPage();// schedule the next fetch after REFRESH_INTERVAL // once the current fetch completes timeoutId =setTimeout(fetchData, REFRESH_INTERVAL); } else {clearTimeout(timeoutId); } }fetchData();return () =>clearTimeout(timeoutId); }, [isLive, fetchPreviousPage]);return <buttononClick={() =>setIsLive(!isLive)}>{isLive ?"Stop live mode":"Start live mode"} </button>}
"use client";import*as React from"react";importtype { FetchPreviousPageOptions } from"@tanstack/react-query";constREFRESH_INTERVAL=5_000; // 5 secondsinterfaceLiveModeButtonProps {fetchPreviousPage?: (options?:FetchPreviousPageOptions|undefined, ) =>Promise<unknown>;}exportfunctionLiveModeButton({ fetchPreviousPage }:LiveModeButtonProps) {// or nuqs [isLive, setIsLive] = useQueryState("live", parseAsBoolean)const [isLive, setIsLive] = React.useState(false); React.useEffect(() => {let timeoutId:NodeJS.Timeout;asyncfunctionfetchData() {if (isLive) {awaitfetchPreviousPage();// schedule the next fetch after REFRESH_INTERVAL // once the current fetch completes timeoutId =setTimeout(fetchData, REFRESH_INTERVAL); } else {clearTimeout(timeoutId); } }fetchData();return () =>clearTimeout(timeoutId); }, [isLive, fetchPreviousPage]);return <buttononClick={() =>setIsLive(!isLive)}> {isLive ?"Stop live mode":"Start live mode"} </button>}
We use setTimeout with recursion rather than setInterval to ensure each refresh only starts after the previous one completes. This prevents multiple simultaneous fetches when network latency exceeds the refresh interval and is a better UX.