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
5b86d6c5 d761c1b5

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

This is a binary file and will not be displayed.

+128 -78
apps/web/src/content/posts/live-mode-infinite-query.mdx
··· 10 10 tag: engineering 11 11 --- 12 12 13 - This article is part of the [logs.run](https://logs.run) series. You can enable the live mode right away via [logs.run/i?live=true](https://logs.run/i?live=true). 13 + This article is part of the [logs.run](https://logs.run) series. 14 14 15 - 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. 15 + You can enable the live mode right away via [logs.run/i?live=true](https://logs.run/i?live=true). Note that it's a demo; the data is mocked and not persisted. Live mode might take a while to load new data. 16 16 17 - ## Basic Concept 17 + 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 a live data update. 18 18 19 - 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`: 20 - 21 - ```ts 22 - const { data, fetchNextPage, fetchPreviousPage } = 23 - useInfiniteQuery(dataOptions); 24 - 25 - const flatData = React.useMemo( 26 - () => data?.pages?.flatMap((page) => page.data ?? []) ?? [], 27 - [data?.pages], 28 - ); 29 - ``` 19 + ## Basic Concept 30 20 31 - Once we understand the _load more_ (append data) functionality, we can mirror this approach to implement _live mode_ (prepend data). 21 + 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. 32 22 33 - Each query requires two key parameters: 23 + Each query to our API endpoint requires two key parameters: 34 24 35 25 1. A `cursor` - a pointer indicating a position in the dataset 36 26 2. A `direction` - specifying whether to fetch data before or after the cursor ("prev" or "next") 37 27 38 - ![Timeline with live mode and load more behavior](/assets/posts/live-mode-infinite-query/infinite-query.png) 28 + A timeline sketch of the infinite query behavior: 39 29 40 - In our implementation, the `cursor` is a timestamp representing the last checked date: 30 + ![Timeline with live mode and load more behavior](/assets/posts/live-mode-infinite-query/infinite-query.png) 41 31 42 - ```ts 43 - const dataOptions = { 44 - queryKey: "data-table", 45 - queryFn: async ({ pageParam }) => { 46 - const { cursor, direction } = pageParam; 47 - const res = await fetch( 48 - `/api/get/data?cursor=${cursor}&direction=${direction}`, 49 - ); 50 - const json = await res.json(); 51 - // For direction "next": { data: [...], nextCursor: 1741526294, prevCursor: null } 52 - // For direction "prev": { data: [...], nextCursor: null, prevCursor: 1741526295 } 53 - return json as ReturnType; 54 - }, 55 - // Initialize with current timestamp for "load more" functionality 56 - initialPageParam: { cursor: new Date().getTime(), direction: "next" }, 57 - getPreviousPageParam: (firstPage, allPages) => { 58 - if (!firstPage.prevCursor) return null; 59 - return { cursor: firstPage.prevCursor, direction: "prev" }; 60 - }, 61 - getNextPageParam: (lastPage, allPages) => { 62 - if (!lastPage.nextCursor) return null; 63 - return { cursor: lastPage.nextCursor, direction: "next" }; 64 - }, 65 - }; 66 - ``` 67 - 68 - 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. 69 - 70 - 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. 32 + ## API Endpoint 71 33 72 34 Your API endpoint should return at minimum: 73 35 74 36 ```ts 75 37 type ReturnType<T> = { 38 + // The single "page" data to be rendered 76 39 data: T[]; 40 + // The timestamp to be used for the next page on _load more_ 77 41 nextCursor?: number | null; 42 + // The timestamp to be used for the previous page on _live mode_ 78 43 prevCursor?: number | null; 79 44 }; 80 45 ``` 81 46 82 - 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()`. 47 + 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()`. 83 48 84 - Example API implementation: 49 + Let's take a look at an example implementation of the API endpoint: 85 50 86 51 ```ts 52 + // import ... 53 + 54 + type TData = { 55 + id: string; 56 + timestamp: number; 57 + // ... 58 + }; 59 + 87 60 export async function GET(req: NextRequest) { 88 61 const searchParams = request.nextUrl.searchParams; 89 62 const cursor = searchParams.get("cursor"); ··· 97 70 WHERE timestamp > ${cursor} AND timestamp <= ${prevCursor} 98 71 ORDER BY timestamp DESC 99 72 `; 100 - const res: ReturnType<MyData> = { data, prevCursor, nextCursor: null }; 73 + const res: ReturnType<TData> = { data, prevCursor, nextCursor: null }; 101 74 return Response.json(res); 102 75 // Load more 103 76 } else { ··· 108 81 LIMIT 40 109 82 `; 110 83 const nextCursor = data.length > 0 ? data[data.length - 1].timestamp : null; 111 - const res: ReturnType<MyData> = { data, nextCursor, prevCursor: null }; 84 + const res: ReturnType<TData> = { data, nextCursor, prevCursor: null }; 112 85 return Response.json(res); 113 86 } 114 87 } ··· 116 89 117 90 Key points: 118 91 119 - - Live mode ("prev" direction): Returns all new data between `Date.now()` and the `cursor` 120 - - Load more ("next" direction): Returns 40 items before the `cursor` and updates `nextCursor` 92 + - _Live mode_ ("prev" direction): Returns all new data between `Date.now()` and the `cursor` from the first page 93 + - _Load more_ ("next" direction): Returns 40 items before the `cursor` of the last page and updates `nextCursor` 121 94 122 - > 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. 95 + > **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. 123 96 124 - ### Avoiding OFFSET with Frequent Data Updates 97 + ### Avoid Using OFFSET with Frequent Data Updates in Non-Live Mode 125 98 126 - 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: 99 + 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: 127 100 128 101 ```ts 102 + const limit = 40; 103 + const offset = limit * cursor; 104 + 129 105 const data = await sql` 130 106 SELECT * FROM table 131 107 ORDER BY timestamp DESC ··· 134 110 `; 135 111 ``` 136 112 113 + When new items are prepended, they shift the offset values, causing duplicate items in subsequent queries. 114 + 137 115 ![Offset caveat example](/assets/posts/live-mode-infinite-query/offset-caveat.png) 138 116 139 - When new items are prepended, they shift the offset values, potentially causing duplicate items in subsequent queries. 117 + ## Client Implementation 140 118 141 - ### Implementing Auto-Refresh 119 + Let's call our API endpoint from the client and use the dedicated infinite query functions that are added to the `useQuery` hook. 142 120 143 - 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: 121 + ```tsx 122 + "use client"; 123 + 124 + import React from "react"; 125 + import { useInfiniteQuery } from "@tanstack/react-query"; 126 + 127 + const dataOptions = { 128 + queryKey: [ 129 + "my-key", 130 + // any other keys, e.g. for search params filters 131 + ], 132 + queryFn: async ({ pageParam }) => { 133 + const { cursor, direction } = pageParam; 134 + const res = await fetch( 135 + `/api/get/data?cursor=${cursor}&direction=${direction}`, 136 + ); 137 + const json = await res.json(); 138 + // For direction "next": { data: [...], nextCursor: 1741526294, prevCursor: null } 139 + // For direction "prev": { data: [...], nextCursor: null, prevCursor: 1741526295 } 140 + return json as ReturnType; 141 + }, 142 + // Initialize with current timestamp and get the most recent data in the past 143 + initialPageParam: { cursor: new Date().getTime(), direction: "next" }, 144 + // Function to fetch newer data 145 + getPreviousPageParam: (firstPage, allPages) => { 146 + if (!firstPage.prevCursor) return null; 147 + return { cursor: firstPage.prevCursor, direction: "prev" }; 148 + }, 149 + // Function to fetch older data 150 + getNextPageParam: (lastPage, allPages) => { 151 + if (!lastPage.nextCursor) return null; 152 + return { cursor: lastPage.nextCursor, direction: "next" }; 153 + }, 154 + }; 155 + 156 + export function Component() { 157 + const { data, fetchNextPage, fetchPreviousPage } = useInfiniteQuery(dataOptions); 158 + 159 + const flatData = React.useMemo( 160 + () => data?.pages?.flatMap((page) => page.data ?? []) ?? [], 161 + [data?.pages], 162 + ); 163 + 164 + return <div>{flatData.map((item) => {/* render item */})}</div>; 165 + } 166 + ``` 167 + 168 + 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. 169 + 170 + 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`](https://tanstack.com/query/latest/docs/framework/react/reference/useInfiniteQuery) docs for more details. 171 + 172 + ## Implementing Auto-Refresh 173 + 174 + 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 new data. 175 + 176 + 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`: 144 177 145 178 ```tsx 179 + "use client"; 180 + 181 + import * as React from "react"; 182 + import type { FetchPreviousPageOptions } from "@tanstack/react-query"; 183 + 146 184 const REFRESH_INTERVAL = 5_000; // 5 seconds 147 185 148 - React.useEffect(() => { 149 - let timeoutId: NodeJS.Timeout; 186 + interface LiveModeButtonProps { 187 + fetchPreviousPage?: ( 188 + options?: FetchPreviousPageOptions | undefined, 189 + ) => Promise<unknown>; 190 + } 191 + 192 + export function LiveModeButton({ fetchPreviousPage }: LiveModeButtonProps) { 193 + // or nuqs [isLive, setIsLive] = useQueryState("live", parseAsBoolean) 194 + const [isLive, setIsLive] = React.useState(false); 150 195 151 - async function fetchData() { 152 - // isLive is a simple boolean from React.useState<boolean>() 153 - // or nuqs useQueryState("live", parseAsBoolean) 154 - if (isLive) { 155 - await fetchPreviousPage?.(); 156 - timeoutId = setTimeout(fetchData, REFRESH_INTERVAL); 157 - } else { 158 - clearTimeout(timeoutId); 159 - } 160 - } 196 + React.useEffect(() => { 197 + let timeoutId: NodeJS.Timeout; 198 + 199 + async function fetchData() { 200 + if (isLive) { 201 + await fetchPreviousPage(); 202 + // schedule the next fetch after REFRESH_INTERVAL 203 + // once the current fetch completes 204 + timeoutId = setTimeout(fetchData, REFRESH_INTERVAL); 205 + } else { 206 + clearTimeout(timeoutId); 207 + } 208 + } 209 + fetchData(); 161 210 162 - fetchData(); 211 + return () => clearTimeout(timeoutId); 212 + }, [isLive, fetchPreviousPage]); 163 213 164 - return () => { 165 - clearTimeout(timeoutId); 166 - }; 167 - }, [isLive, fetchPreviousPage]); 214 + return <button onClick={() => setIsLive(!isLive)}> 215 + {isLive ? "Stop live mode" : "Start live mode"} 216 + </button> 217 + } 168 218 ``` 169 219 170 220 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. 171 221 172 222 --- 173 223 174 - Now check it out on [logs.run/i?live=true](https://logs.run/i?live=true). 224 + Go check it out on [logs.run/i?live=true](https://logs.run/i?live=true). 175 225 176 - 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). 226 + 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) blog post.