Openstatus www.openstatus.dev
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

chore: blog post

authored by

Maximilian Kaske and committed by
Maximilian Kaske
e78ff041 89f744f5

+170
apps/web/public/assets/posts/live-mode-infinite-query/infinite-query.png

This is a binary file and will not be displayed.

apps/web/public/assets/posts/live-mode-infinite-query/offset-caveat.png

This is a binary file and will not be displayed.

apps/web/public/assets/posts/live-mode-infinite-query/tanstack.png

This is a binary file and will not be displayed.

+170
apps/web/src/content/posts/live-mode-infinite-query.mdx
··· 1 + --- 2 + title: Live Mode 3 + description: How we leverage TanStack Infinite Query for implementing live mode 4 + author: 5 + name: Maximilian Kaske 6 + url: https://x.com/mxkaske 7 + avatar: /assets/authors/max.png 8 + publishedAt: 2025-03-15 9 + image: /assets/posts/live-mode-infinite-query/tanstack.png 10 + tag: engineering 11 + --- 12 + 13 + While TanStack provides excellent [documentation on Infinite Queries](https://tanstack.com/query/latest/docs/framework/react/guides/infinite-queries), this article offers an additional practical example focusing on implementing live data updates. 14 + 15 + ## Basic Concept 16 + 17 + Infinite queries work with "pages" of data. Each time you load new data, a new "page" is either appended or prepended to the `data.pages` array. To get a flat list of all data in the correct order, you simply use `flatMap`: 18 + 19 + ```ts 20 + const { data, fetchNextPage, fetchPreviousPage } = 21 + useInfiniteQuery(dataOptions); 22 + 23 + const flatData = React.useMemo( 24 + () => data?.pages?.flatMap((page) => page.data ?? []) ?? [], 25 + [data?.pages], 26 + ); 27 + ``` 28 + 29 + ![Timeline with live mode and load more behavior](/assets/posts/live-mode-infinite-query/infinite-query.png) 30 + 31 + Once we understand the _load more_ (append data) functionality, we can mirror this approach to implement _live mode_ (prepend data). 32 + 33 + Each query requires two key parameters: 34 + 35 + 1. A `cursor` - a pointer indicating a position in the dataset 36 + 2. A `direction` - specifying whether to fetch data before or after the cursor ("prev" or "next") 37 + 38 + In our implementation, the `cursor` is a timestamp representing the last checked date: 39 + 40 + ```ts 41 + const dataOptions = { 42 + queryKey: "data-table", 43 + queryFn: async ({ pageParam }) => { 44 + const { cursor, direction } = pageParam; 45 + const res = await fetch( 46 + `/api/get/data?cursor=${cursor}&direction=${direction}`, 47 + ); 48 + const json = await res.json(); 49 + // For direction "next": { data: [...], nextCursor: 1741526294, prevCursor: null } 50 + // For direction "prev": { data: [...], nextCursor: null, prevCursor: 1741526295 } 51 + return json as ReturnType; 52 + }, 53 + // Initialize with current timestamp for "load more" functionality 54 + initialPageParam: { cursor: new Date().getTime(), direction: "next" }, 55 + getPreviousPageParam: (firstPage, allPages) => { 56 + if (!firstPage.prevCursor) return null; 57 + return { cursor: firstPage.prevCursor, direction: "prev" }; 58 + }, 59 + getNextPageParam: (lastPage, allPages) => { 60 + if (!lastPage.nextCursor) return null; 61 + return { cursor: lastPage.nextCursor, direction: "next" }; 62 + }, 63 + }; 64 + ``` 65 + 66 + The `getPreviousPageParam` and `getNextPageParam` functions receive the first and last pages respectively as their first parameter. This allows us to access the `prevCursor` and `nextCursor` values to track our position when loading more items or checking for updates in live mode. 67 + 68 + 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. 69 + 70 + Your API endpoint should return at minimum: 71 + 72 + ```ts 73 + type ReturnType<T> = { 74 + data: T[]; 75 + nextCursor?: number | null; 76 + prevCursor?: number | null; 77 + }; 78 + ``` 79 + 80 + When fetching older pages ("next" direction), we set a `LIMIT` (e.g., 40 items). However, when fetching newer data ("prev" direction), we return all data between the `prevCursor` and `Date.now()`. 81 + 82 + Example API implementation: 83 + 84 + ```ts 85 + export async function GET(req: NextRequest) { 86 + const searchParams = request.nextUrl.searchParams; 87 + const cursor = searchParams.get("cursor"); 88 + const direction = searchParams.get("direction"); 89 + 90 + // Live mode 91 + if (direction === "prev") { 92 + const prevCursor = Date.now(); 93 + const data = await sql` 94 + SELECT * FROM table 95 + WHERE timestamp > ${cursor} AND timestamp <= ${prevCursor} 96 + ORDER BY timestamp DESC 97 + `; 98 + const res: ReturnType<MyData> = { data, prevCursor, nextCursor: null }; 99 + return Response.json(res); 100 + // Load more 101 + } else { 102 + const data = await sql` 103 + SELECT * FROM table 104 + WHERE timestamp < ${cursor} 105 + ORDER BY timestamp DESC 106 + LIMIT 40 107 + `; 108 + const nextCursor = data.length > 0 ? data[data.length - 1].timestamp : null; 109 + const res: ReturnType<MyData> = { data, nextCursor, prevCursor: null }; 110 + return Response.json(res); 111 + } 112 + } 113 + ``` 114 + 115 + Key points: 116 + 117 + - Live mode ("prev" direction): Returns all new data between `Date.now()` and the `cursor` 118 + - Load more ("next" direction): Returns 40 items before the `cursor` and updates `nextCursor` 119 + 120 + > 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. 121 + 122 + ### Avoiding OFFSET with Frequent Data Updates 123 + 124 + While it might be tempting to use `OFFSET` for pagination (without having live mode active), this approach can cause problems when data is frequently prepended: 125 + 126 + ```ts 127 + const data = await sql` 128 + SELECT * FROM table 129 + ORDER BY timestamp DESC 130 + LIMIT ${limit} 131 + OFFSET ${offset} 132 + `; 133 + ``` 134 + 135 + ![Offset caveat example](/assets/posts/live-mode-infinite-query/offset-caveat.png) 136 + 137 + When new items are prepended, they shift the offset values, potentially causing duplicate items in subsequent queries. 138 + 139 + ### Implementing Auto-Refresh 140 + 141 + While TanStack Query provides a `refetchInterval` option, it would refetch all pages, growing increasingly expensive as more pages are loaded. Instead, we implement a custom refresh mechanism for fetching only new data: 142 + 143 + ```tsx 144 + const REFRESH_INTERVAL = 5_000; // 5 seconds 145 + 146 + React.useEffect(() => { 147 + let timeoutId: NodeJS.Timeout; 148 + 149 + async function fetchData() { 150 + // isLive is a simple boolean from React.useState<boolean>() 151 + // or nuqs useQueryState("live", parseAsBoolean) 152 + if (isLive) { 153 + await fetchPreviousPage?.(); 154 + timeoutId = setTimeout(fetchData, REFRESH_INTERVAL); 155 + } else { 156 + clearTimeout(timeoutId); 157 + } 158 + } 159 + 160 + fetchData(); 161 + 162 + return () => { 163 + clearTimeout(timeoutId); 164 + }; 165 + }, [isLive, fetchPreviousPage]); 166 + ``` 167 + 168 + 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. 169 + 170 + For more details about our data table implementation, check out [The React data-table I always wanted](https://www.openstatus.dev/blog/data-table-redesign).