├── .gitignore ├── .prettierrc ├── README.md ├── bun.lockb ├── eslint.config.mjs ├── next.config.ts ├── package.json ├── postcss.config.mjs ├── public ├── file.svg ├── globe.svg ├── next.svg ├── vercel.svg └── window.svg ├── src └── app │ ├── actions.ts │ ├── api │ └── foo │ │ └── route.ts │ ├── components │ ├── api-infinite-scroll-section.tsx │ ├── bi-infinite-scroll-section.tsx │ ├── bi-virtual-infinite-scroll-section.tsx │ ├── infinite-scrollers.tsx │ ├── inverse-infinite-scroll-section.tsx │ ├── normal-infinite-scroll-section.tsx │ ├── prefetch-infinite-scroll-section.tsx │ ├── uni-virtual-infinite-scroll-section.tsx │ └── virtual-observer-section.tsx │ ├── favicon.ico │ ├── fonts │ ├── GeistMonoVF.woff │ └── GeistVF.woff │ ├── globals.css │ ├── layout.tsx │ ├── page.tsx │ └── providers.tsx ├── tailwind.config.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env* 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["prettier-plugin-tailwindcss"] 3 | } 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Intended to be a complete guide to infinite scroll in React. Nothing to install, just copy & paste then customize. Examples with Next.js / Tailwind / Tanstack Query / and Tanstack Virtual. This will be the best repo to reference when implementing an infinite scroll feature in React. Infinite scroll feature can be quite hard, especially for bi-directional scroll and virtual scroll (for very large list) support. I hope this repo can save people some time. Please star this repo if you find it helpful, thanks. 2 | 3 | ## [Demo](https://stackblitz.com/~/github.com/Apestein/better-react-infinite-scroll) 4 | 5 | ## Intersection observer API (good for small list) 6 | 7 | ### Copy & paste this [component](https://github.com/Apestein/better-react-infinite-scroll/blob/main/src/app/components/infinite-scrollers.tsx) into your codebase and use like below👇 8 | 9 | ### Normal Scroll Example 10 | 11 | ```tsx 12 | "use client"; 13 | import { InfiniteScroller } from "./infinite-scrollers"; 14 | import { useInfiniteQuery } from "@tanstack/react-query"; 15 | import React from "react"; 16 | 17 | //this can be a server action 18 | async function fetchInfiniteData(limit: number, cursor: number = 0) { 19 | const rows = new Array(limit) 20 | .fill(0) 21 | .map((_, i) => `row #${i + cursor * limit}`) 22 | .map((i) => ({ foo: i, id: crypto.randomUUID() })); 23 | 24 | await new Promise((r) => setTimeout(r, 500)); 25 | 26 | return { 27 | rows, 28 | nextCursor: cursor < 4 ? cursor + 1 : null, 29 | }; 30 | } 31 | 32 | export function NormalInfiniteScrollSection() { 33 | const { data, error, fetchNextPage, hasNextPage, status } = useInfiniteQuery({ 34 | queryKey: ["normal-infinite-data"], 35 | queryFn: (ctx) => fetchInfiniteData(10, ctx.pageParam), 36 | initialPageParam: 0, 37 | getNextPageParam: (nextPage, pages) => nextPage.nextCursor, 38 | }); 39 | 40 | if (status === "error") return

Error {error.message}

; 41 | if (status === "pending") 42 | return

Loading from client...

; 43 | return ( 44 |
45 |

Normal Infinite Scroll

46 | 53 | {data.pages.map((page, i) => ( 54 | 55 | {page.rows.map((el) => ( 56 |

57 | {el.foo} 58 |

59 | ))} 60 |
61 | ))} 62 |
63 |
64 | ); 65 | } 66 | ``` 67 | 68 | ### Inverse Scroll Example 69 | 70 | ```tsx 71 | "use client"; 72 | import { InfiniteScroller } from "./infinite-scrollers"; 73 | import { useInfiniteQuery } from "@tanstack/react-query"; 74 | import React from "react"; 75 | 76 | //this can be a server action 77 | async function fetchInfiniteData(limit: number, cursor: number = 0) { 78 | const rows = new Array(limit) 79 | .fill(0) 80 | .map((_, i) => `row #${i + cursor * limit}`) 81 | .map((i) => ({ foo: i, id: crypto.randomUUID() })); 82 | 83 | await new Promise((r) => setTimeout(r, 500)); 84 | 85 | return { 86 | rows, 87 | prevCursor: cursor > 0 ? cursor - 1 : null, 88 | }; 89 | } 90 | 91 | export function InverseInfiniteScrollSection() { 92 | const { data, error, fetchNextPage, hasNextPage, status } = useInfiniteQuery({ 93 | queryKey: ["inverse-infinite-data"], 94 | queryFn: (ctx) => fetchInfiniteData(10, ctx.pageParam), 95 | initialPageParam: 4, 96 | getNextPageParam: (nextPage, pages) => nextPage.prevCursor, 97 | }); 98 | 99 | if (status === "error") return

Error {error.message}

; 100 | if (status === "pending") 101 | return

Loading from client...

; 102 | return ( 103 |
104 |

Inverse Infinite Scroll

105 | 112 | {data.pages.map((page, i) => ( 113 | 114 | {page.rows 115 | .map((el) => ( 116 |

117 | {el.foo} 118 |

119 | )) 120 | .reverse()}{" "} 121 | //reverse the array 122 |
123 | ))} 124 |
125 |
126 | ); 127 | } 128 | ``` 129 | 130 | ### Bi-directional Scroll Example (supports large list by setting maxPages) 131 | 132 | ```tsx 133 | "use client"; 134 | import { BiInfiniteScroller } from "./infinite-scrollers"; 135 | import { useInfiniteQuery } from "@tanstack/react-query"; 136 | import React from "react"; 137 | 138 | //this can be a server action 139 | async function fetchInfiniteData(limit: number, cursor: number = 0) { 140 | const rows = new Array(limit) 141 | .fill(0) 142 | .map((_, i) => `row #${i + cursor * limit}`) 143 | .map((i) => ({ foo: i, id: crypto.randomUUID() })); 144 | 145 | await new Promise((r) => setTimeout(r, 500)); 146 | 147 | return { 148 | rows, 149 | nextCursor: cursor < 4 ? cursor + 1 : null, 150 | prevCursor: cursor > -4 ? cursor - 1 : null, 151 | }; 152 | } 153 | 154 | export function BiInfiniteScrollSection() { 155 | const MAX_PAGES = 3; 156 | const { 157 | data, 158 | error, 159 | fetchNextPage, 160 | fetchPreviousPage, 161 | hasNextPage, 162 | hasPreviousPage, 163 | status, 164 | } = useInfiniteQuery({ 165 | queryKey: ["bi-infinite-data"], 166 | queryFn: (ctx) => fetchInfiniteData(10, ctx.pageParam), 167 | initialPageParam: 0, 168 | getNextPageParam: (nextPage, pages) => nextPage.nextCursor, 169 | getPreviousPageParam: (prevPage, pages) => prevPage.prevCursor, 170 | maxPages: MAX_PAGES, //should only set maxPages for large list, or whenever you notice performance issues 171 | }); 172 | 173 | if (status === "error") return

Error {error.message}

; 174 | if (status === "pending") 175 | return

Loading from client...

; 176 | return ( 177 |
178 |

Bi-directional Infinite Scroll

179 | 192 | {data.pages.map((page, i) => ( 193 | 194 | {page.rows.map((el) => ( 195 |

196 | {el.foo} 197 |

198 | ))} 199 |
200 | ))} 201 |
202 |
203 | ); 204 | } 205 | ``` 206 | 207 | ## Tanstack Virtual (good for large list) 208 | 209 | ### Normal Virtual Scroll Example 210 | 211 | ```tsx 212 | /* eslint-disable react-hooks/exhaustive-deps */ 213 | "use client"; 214 | import React from "react"; 215 | import { useInfiniteQuery } from "@tanstack/react-query"; 216 | import { useVirtualizer } from "@tanstack/react-virtual"; 217 | 218 | async function fetchInfiniteData(limit: number, offset: number = 0) { 219 | const rows = new Array(limit) 220 | .fill(0) 221 | .map((_, i) => `row #${i + offset * limit}`); 222 | 223 | await new Promise((r) => setTimeout(r, 500)); 224 | 225 | return { 226 | rows, 227 | nextOffset: offset + 1, 228 | }; 229 | } 230 | 231 | export function UniVirtualInfiniteScrollSection() { 232 | const PAGE_SIZE = 10000; 233 | const { 234 | status, 235 | data, 236 | error, 237 | isFetchingNextPage, 238 | fetchNextPage, 239 | hasNextPage, 240 | } = useInfiniteQuery({ 241 | queryKey: ["uni-virtual-infinite-data"], 242 | queryFn: (ctx) => fetchInfiniteData(PAGE_SIZE, ctx.pageParam), 243 | getNextPageParam: (lastGroup) => lastGroup.nextOffset, 244 | initialPageParam: 0, 245 | }); 246 | 247 | const parentRef = React.useRef(null); 248 | 249 | const allRows = data ? data.pages.flatMap((d) => d.rows) : []; 250 | 251 | const rowVirtualizer = useVirtualizer({ 252 | count: allRows.length + 1, //plus 1 for bottom loader row 253 | getScrollElement: () => parentRef.current, 254 | estimateSize: () => 100, 255 | overscan: 0, 256 | }); 257 | 258 | React.useEffect(() => { 259 | const [lastItem] = [...rowVirtualizer.getVirtualItems()].reverse(); 260 | 261 | if (!lastItem) return; 262 | 263 | if ( 264 | lastItem.index >= allRows.length - 1 && 265 | hasNextPage && 266 | !isFetchingNextPage 267 | ) { 268 | fetchNextPage(); 269 | } 270 | }, [ 271 | hasNextPage, 272 | fetchNextPage, 273 | allRows.length, 274 | isFetchingNextPage, 275 | rowVirtualizer.getVirtualItems(), 276 | ]); 277 | 278 | if (status === "error") return

Error {error.message}

; 279 | if (status === "pending") 280 | return

Loading from client...

; 281 | return ( 282 |
283 |

Virtual Infinite Scroll

284 |
288 |
294 | {rowVirtualizer.getVirtualItems().map((virtualRow) => { 295 | const isLoaderBottom = virtualRow.index > allRows.length - 1; 296 | const post = allRows[virtualRow.index]; 297 | return ( 298 |
306 | {isLoaderBottom 307 | ? hasNextPage 308 | ? "Loading next page..." 309 | : "No more next page" 310 | : post} 311 |
312 | ); 313 | })} 314 |
315 |
316 |
317 | ); 318 | } 319 | ``` 320 | 321 | ### Bi-directional Virtual Scroll Example (doesn't work for dynamic size list items) 322 | 323 | ```tsx 324 | /* eslint-disable react-hooks/exhaustive-deps */ 325 | "use client"; 326 | import React from "react"; 327 | import { useInfiniteQuery } from "@tanstack/react-query"; 328 | import { useVirtualizer } from "@tanstack/react-virtual"; 329 | 330 | async function fetchInfiniteData(limit: number, offset: number = 0) { 331 | const rows = new Array(limit) 332 | .fill(0) 333 | .map((_, i) => `row #${i + offset * limit}`); 334 | 335 | await new Promise((r) => setTimeout(r, 500)); 336 | 337 | return { 338 | rows, 339 | nextOffset: offset < 4 ? offset + 1 : null, 340 | prevOffset: offset > -4 ? offset - 1 : null, 341 | }; 342 | } 343 | 344 | export function BiVirtualInfiniteScrollSection() { 345 | const PAGE_SIZE = 10; 346 | const { 347 | status, 348 | data, 349 | error, 350 | isFetchingNextPage, 351 | isFetchingPreviousPage, 352 | fetchNextPage, 353 | fetchPreviousPage, 354 | hasNextPage, 355 | hasPreviousPage, 356 | } = useInfiniteQuery({ 357 | queryKey: ["bi-virtual-infinite-data"], 358 | queryFn: (ctx) => fetchInfiniteData(PAGE_SIZE, ctx.pageParam), 359 | getNextPageParam: (lastGroup) => lastGroup.nextOffset, 360 | getPreviousPageParam: (firstGroup) => firstGroup.prevOffset, 361 | initialPageParam: 0, 362 | }); 363 | 364 | const parentRef = React.useRef(null); 365 | const backwardScrollRef = React.useRef(null); 366 | const dirtyHack = React.useRef(false); 367 | 368 | const allRows = data ? data.pages.flatMap((d) => d.rows) : []; 369 | 370 | const rowVirtualizer = useVirtualizer({ 371 | count: allRows.length + 2, //plus 2 for top & bottom loader row 372 | getScrollElement: () => parentRef.current, 373 | estimateSize: () => 100, 374 | overscan: 0, 375 | }); 376 | 377 | React.useEffect(() => { 378 | const [firstItem] = [...rowVirtualizer.getVirtualItems()]; 379 | const [lastItem] = [...rowVirtualizer.getVirtualItems()].reverse(); 380 | 381 | if (!lastItem || !firstItem) return; 382 | 383 | if ( 384 | lastItem.index >= allRows.length - 1 && 385 | hasNextPage && 386 | !isFetchingNextPage 387 | ) { 388 | fetchNextPage(); 389 | } else if ( 390 | firstItem.index === 0 && 391 | hasPreviousPage && 392 | !isFetchingPreviousPage 393 | ) { 394 | if (dirtyHack.current) { 395 | dirtyHack.current = false; 396 | return; 397 | } 398 | fetchPreviousPage().finally(() => { 399 | // rowVirtualizer.scrollToIndex(10, { align: "start" }) //trying to scroll here seems to cause race condition that sometimes will prevent fetching next page 400 | backwardScrollRef.current = crypto.randomUUID(); //hack to prevent race condition 401 | dirtyHack.current = true; //dirty hack to prevent double fetch 402 | }); 403 | } 404 | }, [ 405 | hasNextPage, 406 | hasPreviousPage, 407 | fetchNextPage, 408 | fetchPreviousPage, 409 | allRows.length, 410 | isFetchingNextPage, 411 | isFetchingPreviousPage, 412 | rowVirtualizer.getVirtualItems(), 413 | ]); 414 | 415 | //preserve scroll position for backwards scroll, forward scroll is already handled automatically 416 | React.useEffect(() => { 417 | if (!backwardScrollRef.current) return; 418 | rowVirtualizer.scrollToIndex(PAGE_SIZE, { align: "start" }); 419 | }, [backwardScrollRef.current]); 420 | 421 | //scroll to bottom on initial load, delete if you don't want this behavior 422 | React.useEffect(() => { 423 | dirtyHack.current = false; //more hacky shit... 424 | if (allRows.length === PAGE_SIZE) { 425 | rowVirtualizer.scrollToIndex(PAGE_SIZE); 426 | } 427 | }, [allRows.length]); 428 | 429 | if (status === "error") return

Error {error.message}

; 430 | if (status === "pending") 431 | return

Loading from client...

; 432 | return ( 433 |
434 |

Bi-directional Virtual Infinite Scroll

435 |
439 |
445 | {rowVirtualizer.getVirtualItems().map((virtualRow) => { 446 | const isLoaderTop = virtualRow.index === 0; 447 | const isLoaderBottom = virtualRow.index > allRows.length; 448 | const post = allRows[virtualRow.index - 1]; 449 | return ( 450 |
458 | {isLoaderTop 459 | ? hasPreviousPage 460 | ? "Loading previous page..." 461 | : "No more previous page" 462 | : isLoaderBottom 463 | ? hasNextPage 464 | ? "Loading next page..." 465 | : "No more next page" 466 | : post} 467 |
468 | ); 469 | })} 470 |
471 |
472 |
473 | ); 474 | } 475 | ``` 476 | 477 | ## More Examples 478 | 479 | ### Prefetch Suspense with Next.js server actions (prefetch on the server, initial load will be faster) 480 | 481 | ```tsx 482 | ///page.tsx 483 | import React, { Suspense } from "react"; 484 | import { NormalInfiniteScrollSection } from "./components/normal-infinite-scroll-section"; 485 | import { InverseInfiniteScrollSection } from "./components/inverse-infinite-scroll-section"; 486 | import { BiInfiniteScrollSection } from "./components/bi-infinite-scroll-section"; 487 | import { ApiInfiniteScrollSection } from "./components/api-infinite-scroll-section"; 488 | import { PreInfiniteScrollSection } from "./components/prefetch-infinite-scroll-section"; 489 | import { UniVirtualInfiniteScrollSection } from "./components/uni-virtual-infinite-scroll-section"; 490 | import { BiVirtualInfiniteScrollSection } from "./components/bi-virtual-infinite-scroll-section"; 491 | import { 492 | dehydrate, 493 | HydrationBoundary, 494 | QueryClient, 495 | } from "@tanstack/react-query"; 496 | import { getInfiniteDataAction } from "./actions"; 497 | 498 | export default async function Home() { 499 | return ( 500 |
501 |
502 |

503 | Good for small list 504 |

505 | 506 | 507 | 508 |
509 |
510 |

511 | Good for large list 512 |

513 | 514 | 515 |
516 |
517 |

518 | More examples 519 |

520 | 521 | 522 | 523 | 524 |
525 |
526 | ); 527 | } 528 | 529 | async function PrefetchWrapper() { 530 | const queryClient = new QueryClient(); 531 | await queryClient.prefetchInfiniteQuery({ 532 | queryKey: ["prefetch-infinite-data"], 533 | queryFn: (ctx) => getInfiniteDataAction(10, ctx.pageParam), 534 | initialPageParam: 0, 535 | // getNextPageParam: (lastPage, pages) => lastPage.nextCursor, 536 | // pages: 3, //number of pages to prefetch 537 | }); 538 | 539 | return ( 540 | 541 | 542 | 543 | ); 544 | } 545 | 546 | //prefetch-infinite-scroll-section.tsx 547 | ("use client"); 548 | import { InfiniteScroller } from "./infinite-scrollers"; 549 | import { getInfiniteDataAction } from "../actions"; 550 | import { useInfiniteQuery } from "@tanstack/react-query"; 551 | import React from "react"; 552 | 553 | export function PreInfiniteScrollSection() { 554 | const { data, error, fetchNextPage, hasNextPage, status } = useInfiniteQuery({ 555 | queryKey: ["prefetch-infinite-data"], 556 | queryFn: (ctx) => getInfiniteDataAction(10, ctx.pageParam), 557 | initialPageParam: 0, 558 | getNextPageParam: (nextPage, pages) => nextPage.nextCursor, 559 | }); 560 | 561 | if (status === "error") return

Error {error.message}

; 562 | if (status === "pending") 563 | return

Loading from client...

; 564 | return ( 565 |
566 |

Prefetch suspense example

567 |

this loads faster

568 | 575 | {data.pages.map((page, i) => ( 576 | 577 | {page.rows.map((el) => ( 578 |

579 | {el.foo} 580 |

581 | ))} 582 |
583 | ))} 584 |
585 |
586 | ); 587 | } 588 | ``` 589 | 590 | ### Route handler example with Next.js 591 | 592 | ```tsx 593 | //app/api/foo/route.ts 594 | import { type NextRequest, NextResponse } from "next/server"; 595 | 596 | export async function GET(request: NextRequest) { 597 | const searchParams = request.nextUrl.searchParams; 598 | const cursor = Number(searchParams.get("cursor")); 599 | const limit = Number(searchParams.get("limit")); 600 | if (cursor == null || limit == null) 601 | return NextResponse.json( 602 | { error: "Failed to process request" }, 603 | { status: 400 }, 604 | ); 605 | const rows = new Array(limit) 606 | .fill(0) 607 | .map((_, i) => `row #${i + cursor * limit}`) 608 | .map((i) => ({ foo: i, id: crypto.randomUUID() })); 609 | 610 | await new Promise((r) => setTimeout(r, 500)); 611 | 612 | const res = { 613 | rows, 614 | nextCursor: cursor < 4 ? cursor + 1 : null, 615 | }; 616 | return NextResponse.json(res); 617 | } 618 | 619 | //api-infinite-scroll-section.tsx 620 | ("use client"); 621 | import { InfiniteScroller } from "./infinite-scrollers"; 622 | import { useInfiniteQuery } from "@tanstack/react-query"; 623 | import React from "react"; 624 | 625 | async function fetchInfiniteData(limit: number, cursor: number) { 626 | const res = await fetch(`/api/foo?cursor=${cursor}&limit=${limit}`); 627 | return res.json() as Promise<{ 628 | rows: { 629 | foo: string; 630 | id: string; 631 | }[]; 632 | nextCursor: number | null; 633 | }>; //use return type from api route 634 | } 635 | 636 | export function ApiInfiniteScrollSection() { 637 | const { data, error, fetchNextPage, hasNextPage, status } = useInfiniteQuery({ 638 | queryKey: ["api-infinite-data"], 639 | queryFn: (ctx) => fetchInfiniteData(10, ctx.pageParam), 640 | initialPageParam: 0, 641 | getNextPageParam: (nextPage, pages) => nextPage.nextCursor, 642 | }); 643 | 644 | if (status === "error") return

Error {error.message}

; 645 | if (status === "pending") 646 | return

Loading from client...

; 647 | return ( 648 |
649 |

Route-handler Example

650 |

this loads slower than prefetch

651 | 658 | {data.pages.map((page, i) => ( 659 | 660 | {page.rows.map((el) => ( 661 |

662 | {el.foo} 663 |

664 | ))} 665 |
666 | ))} 667 |
668 |
669 | ); 670 | } 671 | ``` 672 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Apestein/better-react-infinite-scroll/7d3998fd0db287447a365a3dfcbe775acc195716/bun.lockb -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { dirname } from "path"; 2 | import { fileURLToPath } from "url"; 3 | import { FlatCompat } from "@eslint/eslintrc"; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = dirname(__filename); 7 | 8 | const compat = new FlatCompat({ 9 | baseDirectory: __dirname, 10 | }); 11 | 12 | // const eslintConfig = [ 13 | // ...compat.extends("next/core-web-vitals", "next/typescript"), 14 | // ]; 15 | 16 | const eslintConfig = [ 17 | ...compat.config({ 18 | extends: ["next/core-web-vitals", "next/typescript"], 19 | rules: { 20 | "@typescript-eslint/no-unused-vars": "warn", 21 | }, 22 | }), 23 | ]; 24 | 25 | export default eslintConfig; 26 | -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const nextConfig: NextConfig = { 4 | /* config options here */ 5 | }; 6 | 7 | export default nextConfig; 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "best-react-infinite-scroll", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev --turbopack", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@tanstack/react-query": "^5.62.7", 13 | "@tanstack/react-virtual": "^3.11.1", 14 | "next": "15.1.0", 15 | "react": "^19.0.0", 16 | "react-dom": "^19.0.0" 17 | }, 18 | "devDependencies": { 19 | "@eslint/eslintrc": "^3", 20 | "@tanstack/react-query-devtools": "^5.62.7", 21 | "@types/node": "^20", 22 | "@types/react": "^19", 23 | "@types/react-dom": "^19", 24 | "eslint": "^9", 25 | "eslint-config-next": "15.1.0", 26 | "postcss": "^8", 27 | "prettier": "^3.4.2", 28 | "prettier-plugin-tailwindcss": "^0.6.9", 29 | "tailwindcss": "^3.4.1", 30 | "typescript": "^5" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /public/file.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/globe.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/window.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | /* 3 | Mock database access, returns next 10 numbers. 4 | Cursor will be automatically passed when calling fetchNextPage & fetchPreviousPage 5 | */ 6 | export async function getFooAction(cursor: number) { 7 | await new Promise((resolve) => setTimeout(resolve, 500)); 8 | const range = (start: number, stop: number, step: number) => 9 | Array.from({ length: (stop - start) / step + 1 }, (_, i) => ({ 10 | foo: start + i * step, 11 | id: crypto.randomUUID().toString(), 12 | })); 13 | return { 14 | data: range(cursor, cursor + 9, 1), 15 | nextCursor: cursor < 40 ? cursor + 9 + 1 : null, 16 | prevCursor: cursor > 0 ? cursor - 9 - 1 : null, 17 | }; 18 | } 19 | 20 | //reverse data for inverse scroll 21 | export async function getLatestFooAction(cursor: number) { 22 | await new Promise((resolve) => setTimeout(resolve, 500)); 23 | const range = (start: number, stop: number, step: number) => 24 | Array.from({ length: (stop - start) / step + 1 }, (_, i) => ({ 25 | foo: start + i * step, 26 | id: crypto.randomUUID().toString(), 27 | })); 28 | return { 29 | data: range(cursor, cursor + 9, 1).reverse(), 30 | nextCursor: cursor < 40 ? cursor + 9 + 1 : null, 31 | prevCursor: cursor > 0 ? cursor - 9 - 1 : null, 32 | }; 33 | } 34 | 35 | export async function getInfiniteDataAction(limit: number, cursor: number = 0) { 36 | const rows = new Array(limit) 37 | .fill(0) 38 | .map((_, i) => `row #${i + cursor * limit}`) 39 | .map((i) => ({ foo: i, id: crypto.randomUUID() })); 40 | 41 | await new Promise((r) => setTimeout(r, 500)); 42 | 43 | return { 44 | rows, 45 | nextCursor: cursor < 4 ? cursor + 1 : null, 46 | prevCursor: cursor > -4 ? cursor - 1 : null, 47 | }; 48 | } 49 | -------------------------------------------------------------------------------- /src/app/api/foo/route.ts: -------------------------------------------------------------------------------- 1 | import { type NextRequest, NextResponse } from "next/server"; 2 | 3 | export async function GET(request: NextRequest) { 4 | const searchParams = request.nextUrl.searchParams; 5 | const cursor = Number(searchParams.get("cursor")); 6 | const limit = Number(searchParams.get("limit")); 7 | if (cursor == null || limit == null) 8 | return NextResponse.json( 9 | { error: "Failed to process request" }, 10 | { status: 400 }, 11 | ); 12 | const rows = new Array(limit) 13 | .fill(0) 14 | .map((_, i) => `row #${i + cursor * limit}`) 15 | .map((i) => ({ foo: i, id: crypto.randomUUID() })); 16 | 17 | await new Promise((r) => setTimeout(r, 500)); 18 | 19 | const res = { 20 | rows, 21 | nextCursor: cursor < 4 ? cursor + 1 : null, 22 | }; 23 | return NextResponse.json(res); 24 | } 25 | -------------------------------------------------------------------------------- /src/app/components/api-infinite-scroll-section.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { InfiniteScroller } from "./infinite-scrollers"; 3 | import { useInfiniteQuery } from "@tanstack/react-query"; 4 | import React from "react"; 5 | 6 | async function fetchInfiniteData(limit: number, cursor: number) { 7 | const res = await fetch(`/api/foo?cursor=${cursor}&limit=${limit}`); 8 | return res.json() as Promise<{ 9 | rows: { 10 | foo: string; 11 | id: string; 12 | }[]; 13 | nextCursor: number | null; 14 | }>; //use return type from api route 15 | } 16 | 17 | export function ApiInfiniteScrollSection() { 18 | const { data, error, fetchNextPage, hasNextPage, status } = useInfiniteQuery({ 19 | queryKey: ["api-infinite-data"], 20 | queryFn: (ctx) => fetchInfiniteData(10, ctx.pageParam), 21 | initialPageParam: 0, 22 | getNextPageParam: (nextPage, pages) => nextPage.nextCursor, 23 | }); 24 | 25 | if (status === "error") return

Error {error.message}

; 26 | if (status === "pending") 27 | return

Loading from client...

; 28 | return ( 29 |
30 |

Route-handler Example

31 |

this loads slower than prefetch

32 | 39 | {data.pages.map((page, i) => ( 40 | 41 | {page.rows.map((el) => ( 42 |

43 | {el.foo} 44 |

45 | ))} 46 |
47 | ))} 48 |
49 |
50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /src/app/components/bi-infinite-scroll-section.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { BiInfiniteScroller } from "./infinite-scrollers"; 3 | import { useInfiniteQuery } from "@tanstack/react-query"; 4 | import React from "react"; 5 | 6 | //this can be a server action 7 | async function fetchInfiniteData(limit: number, cursor: number = 0) { 8 | const rows = new Array(limit) 9 | .fill(0) 10 | .map((_, i) => `row #${i + cursor * limit}`) 11 | .map((i) => ({ foo: i, id: crypto.randomUUID() })); 12 | 13 | await new Promise((r) => setTimeout(r, 500)); 14 | 15 | return { 16 | rows, 17 | nextCursor: cursor < 4 ? cursor + 1 : null, 18 | prevCursor: cursor > -4 ? cursor - 1 : null, 19 | }; 20 | } 21 | 22 | export function BiInfiniteScrollSection() { 23 | const MAX_PAGES = 3; 24 | const { 25 | data, 26 | error, 27 | fetchNextPage, 28 | fetchPreviousPage, 29 | hasNextPage, 30 | hasPreviousPage, 31 | status, 32 | } = useInfiniteQuery({ 33 | queryKey: ["bi-infinite-data"], 34 | queryFn: (ctx) => fetchInfiniteData(10, ctx.pageParam), 35 | initialPageParam: 0, 36 | getNextPageParam: (nextPage, pages) => nextPage.nextCursor, 37 | getPreviousPageParam: (prevPage, pages) => prevPage.prevCursor, 38 | maxPages: MAX_PAGES, //should only set maxPages for large list, or whenever you notice performance issues 39 | }); 40 | 41 | if (status === "error") return

Error {error.message}

; 42 | if (status === "pending") 43 | return

Loading from client...

; 44 | return ( 45 |
46 |

Bi-directional Infinite Scroll

47 | 60 | {data.pages.map((page, i) => ( 61 | 62 | {page.rows.map((el) => ( 63 |

64 | {el.foo} 65 |

66 | ))} 67 |
68 | ))} 69 |
70 |
71 | ); 72 | } 73 | -------------------------------------------------------------------------------- /src/app/components/bi-virtual-infinite-scroll-section.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react-hooks/exhaustive-deps */ 2 | /* eslint-disable react/no-unescaped-entities */ 3 | "use client"; 4 | import React from "react"; 5 | import { useInfiniteQuery } from "@tanstack/react-query"; 6 | import { useVirtualizer } from "@tanstack/react-virtual"; 7 | 8 | async function fetchInfiniteData(limit: number, offset: number = 0) { 9 | const rows = new Array(limit) 10 | .fill(0) 11 | .map((_, i) => `row #${i + offset * limit}`); 12 | 13 | await new Promise((r) => setTimeout(r, 500)); 14 | 15 | return { 16 | rows, 17 | nextOffset: offset < 4 ? offset + 1 : null, 18 | prevOffset: offset > -4 ? offset - 1 : null, 19 | }; 20 | } 21 | 22 | export function BiVirtualInfiniteScrollSection() { 23 | const PAGE_SIZE = 10; 24 | const { 25 | status, 26 | data, 27 | error, 28 | isFetchingNextPage, 29 | isFetchingPreviousPage, 30 | fetchNextPage, 31 | fetchPreviousPage, 32 | hasNextPage, 33 | hasPreviousPage, 34 | } = useInfiniteQuery({ 35 | queryKey: ["bi-virtual-infinite-data"], 36 | queryFn: (ctx) => fetchInfiniteData(PAGE_SIZE, ctx.pageParam), 37 | getNextPageParam: (lastGroup) => lastGroup.nextOffset, 38 | getPreviousPageParam: (firstGroup) => firstGroup.prevOffset, 39 | initialPageParam: 0, 40 | }); 41 | 42 | const parentRef = React.useRef(null); 43 | const backwardScrollRef = React.useRef(null); 44 | const dirtyHack = React.useRef(false); 45 | 46 | const allRows = data ? data.pages.flatMap((d) => d.rows) : []; 47 | 48 | const rowVirtualizer = useVirtualizer({ 49 | count: allRows.length + 2, //plus 2 for top & bottom loader row 50 | getScrollElement: () => parentRef.current, 51 | estimateSize: () => 100, 52 | overscan: 0, 53 | }); 54 | 55 | React.useEffect(() => { 56 | const [firstItem] = [...rowVirtualizer.getVirtualItems()]; 57 | const [lastItem] = [...rowVirtualizer.getVirtualItems()].reverse(); 58 | 59 | if (!lastItem || !firstItem) return; 60 | 61 | if ( 62 | lastItem.index >= allRows.length - 1 && 63 | hasNextPage && 64 | !isFetchingNextPage 65 | ) { 66 | fetchNextPage(); 67 | } else if ( 68 | firstItem.index === 0 && 69 | hasPreviousPage && 70 | !isFetchingPreviousPage 71 | ) { 72 | if (dirtyHack.current) { 73 | dirtyHack.current = false; 74 | return; 75 | } 76 | fetchPreviousPage().finally(() => { 77 | // rowVirtualizer.scrollToIndex(10, { align: "start" }) //trying to scroll here seems to cause race condition that sometimes will prevent fetching next page 78 | backwardScrollRef.current = crypto.randomUUID(); //hack to prevent race condition 79 | dirtyHack.current = true; //dirty hack to prevent double fetch 80 | }); 81 | } 82 | }, [ 83 | hasNextPage, 84 | hasPreviousPage, 85 | fetchNextPage, 86 | fetchPreviousPage, 87 | allRows.length, 88 | isFetchingNextPage, 89 | isFetchingPreviousPage, 90 | rowVirtualizer.getVirtualItems(), 91 | ]); 92 | 93 | //preserve scroll position for backwards scroll, forward scroll is already handled automatically 94 | React.useEffect(() => { 95 | if (!backwardScrollRef.current) return; 96 | rowVirtualizer.scrollToIndex(PAGE_SIZE, { align: "start" }); 97 | }, [backwardScrollRef.current]); 98 | 99 | //scroll to bottom on initial load, delete if you don't want this behavior 100 | React.useEffect(() => { 101 | dirtyHack.current = false; //more hacky shit... 102 | if (allRows.length === PAGE_SIZE) { 103 | rowVirtualizer.scrollToIndex(PAGE_SIZE); 104 | } 105 | }, [allRows.length]); 106 | 107 | if (status === "error") return

Error {error.message}

; 108 | if (status === "pending") 109 | return

Loading from client...

; 110 | return ( 111 |
112 |

Bi-directional Virtual Infinite Scroll

113 |
117 |
123 | {rowVirtualizer.getVirtualItems().map((virtualRow) => { 124 | const isLoaderTop = virtualRow.index === 0; 125 | const isLoaderBottom = virtualRow.index > allRows.length; 126 | const post = allRows[virtualRow.index - 1]; 127 | return ( 128 |
136 | {isLoaderTop 137 | ? hasPreviousPage 138 | ? "Loading previous page..." 139 | : "No more previous page" 140 | : isLoaderBottom 141 | ? hasNextPage 142 | ? "Loading next page..." 143 | : "No more next page" 144 | : post} 145 |
146 | ); 147 | })} 148 |
149 |
150 |
151 | ); 152 | } 153 | -------------------------------------------------------------------------------- /src/app/components/infinite-scrollers.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/display-name */ 2 | /* eslint-disable react-hooks/exhaustive-deps */ 3 | /* eslint-disable @typescript-eslint/no-explicit-any */ 4 | import React from "react"; 5 | 6 | interface InfiniteScrollProps extends React.HTMLAttributes { 7 | fetchNextPage: () => Promise; 8 | hasNextPage: boolean; 9 | loadingMessage: React.ReactNode; 10 | endingMessage: React.ReactNode; 11 | } 12 | 13 | export const InfiniteScroller = React.forwardRef< 14 | HTMLDivElement, 15 | InfiniteScrollProps 16 | >( 17 | ( 18 | { 19 | fetchNextPage, 20 | hasNextPage, 21 | endingMessage, 22 | loadingMessage, 23 | children, 24 | ...props 25 | }, 26 | ref, 27 | ) => { 28 | const observerTarget = React.useRef(null); 29 | 30 | React.useEffect(() => { 31 | const observer = new IntersectionObserver( 32 | (entries) => { 33 | if (entries[0]?.isIntersecting && hasNextPage) fetchNextPage(); 34 | }, 35 | { threshold: 1 }, 36 | ); 37 | 38 | if (observerTarget.current) { 39 | observer.observe(observerTarget.current); 40 | } 41 | 42 | return () => observer.disconnect(); 43 | }, [hasNextPage]); 44 | 45 | return ( 46 |
47 | {children} 48 | {hasNextPage ? loadingMessage : endingMessage} 49 |
50 |
51 | ); 52 | }, 53 | ); 54 | 55 | interface BiInfiniteScrollProps extends React.HTMLAttributes { 56 | fetchNextPage: () => Promise; 57 | fetchPreviousPage: () => Promise; 58 | hasNextPage: boolean; 59 | hasPreviousPage: boolean; 60 | loadingMessage: React.ReactNode; 61 | endingMessage: React.ReactNode; 62 | useMaxPages?: { maxPages: number; pageParamsLength: number }; 63 | } 64 | 65 | export function BiInfiniteScroller({ 66 | fetchNextPage, 67 | fetchPreviousPage, 68 | hasNextPage, 69 | hasPreviousPage, 70 | endingMessage, 71 | loadingMessage, 72 | useMaxPages, 73 | children, 74 | ...props 75 | }: BiInfiniteScrollProps) { 76 | const nextObserverTarget = React.useRef(null); 77 | const prevObserverTarget = React.useRef(null); 78 | const parentRef = React.useRef(null); 79 | const prevScrollHeight = React.useRef(null); 80 | const prevScrollTop = React.useRef(null); 81 | const nextAnchor = React.useRef(null); 82 | const prevAnchor = React.useRef(null); 83 | 84 | React.useEffect(() => { 85 | const observer = new IntersectionObserver( 86 | (entries) => { 87 | if (entries.at(0)?.isIntersecting) { 88 | if ( 89 | entries.at(0)?.target === nextObserverTarget.current && 90 | hasNextPage 91 | ) { 92 | if ( 93 | (!useMaxPages || 94 | useMaxPages.pageParamsLength < useMaxPages.maxPages) && 95 | parentRef.current 96 | ) { 97 | prevScrollTop.current = parentRef.current.scrollTop; 98 | prevScrollHeight.current = parentRef.current.scrollHeight; 99 | } 100 | nextAnchor.current = crypto.randomUUID(); 101 | fetchNextPage(); 102 | } else if ( 103 | entries.at(0)?.target === prevObserverTarget.current && 104 | hasPreviousPage 105 | ) { 106 | if ( 107 | (!useMaxPages || 108 | useMaxPages.pageParamsLength < useMaxPages.maxPages) && 109 | parentRef.current 110 | ) { 111 | if (prevScrollTop.current && prevScrollHeight.current) 112 | prevScrollTop.current += 113 | parentRef.current.scrollHeight - prevScrollHeight.current; 114 | prevScrollHeight.current = parentRef.current.scrollHeight; 115 | } 116 | prevAnchor.current = crypto.randomUUID(); 117 | fetchPreviousPage(); 118 | } 119 | } 120 | }, 121 | { threshold: 1 }, 122 | ); 123 | 124 | if (nextObserverTarget.current) { 125 | observer.observe(nextObserverTarget.current); 126 | } 127 | if (prevObserverTarget.current) { 128 | observer.observe(prevObserverTarget.current); 129 | } 130 | 131 | return () => observer.disconnect(); 132 | }, [hasNextPage, hasPreviousPage, useMaxPages?.pageParamsLength]); 133 | 134 | React.useEffect(() => { 135 | if (parentRef.current && !prevScrollHeight.current) 136 | parentRef.current.scrollTop = parentRef.current.scrollHeight; //scroll to bottom on initial load, delete if you don't want this behavior 137 | 138 | if (parentRef.current && prevScrollHeight.current) { 139 | parentRef.current.scrollTop = 140 | parentRef.current.scrollHeight - prevScrollHeight.current; //restore scroll position for backwards scroll 141 | } 142 | }, [prevAnchor.current]); 143 | 144 | React.useEffect(() => { 145 | if (useMaxPages && parentRef.current && prevScrollTop.current) { 146 | parentRef.current.scrollTop = prevScrollTop.current; //restore scroll position for forward scroll 147 | } 148 | }, [nextAnchor.current]); 149 | 150 | return ( 151 |
152 |
153 | {hasPreviousPage ? loadingMessage : endingMessage} 154 | {children} 155 | {hasNextPage ? loadingMessage : endingMessage} 156 |
157 |
158 | ); 159 | } 160 | -------------------------------------------------------------------------------- /src/app/components/inverse-infinite-scroll-section.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { InfiniteScroller } from "./infinite-scrollers"; 3 | import { useInfiniteQuery } from "@tanstack/react-query"; 4 | import React from "react"; 5 | 6 | //this can be a server action 7 | async function fetchInfiniteData(limit: number, cursor: number = 0) { 8 | const rows = new Array(limit) 9 | .fill(0) 10 | .map((_, i) => `row #${i + cursor * limit}`) 11 | .map((i) => ({ foo: i, id: crypto.randomUUID() })); 12 | 13 | await new Promise((r) => setTimeout(r, 500)); 14 | 15 | return { 16 | rows, 17 | prevCursor: cursor > 0 ? cursor - 1 : null, 18 | }; 19 | } 20 | 21 | export function InverseInfiniteScrollSection() { 22 | const { data, error, fetchNextPage, hasNextPage, status } = useInfiniteQuery({ 23 | queryKey: ["inverse-infinite-data"], 24 | queryFn: (ctx) => fetchInfiniteData(10, ctx.pageParam), 25 | initialPageParam: 4, 26 | getNextPageParam: (nextPage, pages) => nextPage.prevCursor, 27 | }); 28 | 29 | if (status === "error") return

Error {error.message}

; 30 | if (status === "pending") 31 | return

Loading from client...

; 32 | return ( 33 |
34 |

Inverse Infinite Scroll

35 | 42 | {data.pages.map((page, i) => ( 43 | 44 | {page.rows 45 | .map((el) => ( 46 |

47 | {el.foo} 48 |

49 | )) 50 | .reverse()} 51 |
52 | ))} 53 |
54 |
55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /src/app/components/normal-infinite-scroll-section.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { InfiniteScroller } from "./infinite-scrollers"; 3 | import { useInfiniteQuery } from "@tanstack/react-query"; 4 | import React from "react"; 5 | 6 | //this can be a server action 7 | async function fetchInfiniteData(limit: number, cursor: number = 0) { 8 | const rows = new Array(limit) 9 | .fill(0) 10 | .map((_, i) => `row #${i + cursor * limit}`) 11 | .map((i) => ({ foo: i, id: crypto.randomUUID() })); 12 | 13 | await new Promise((r) => setTimeout(r, 500)); 14 | 15 | return { 16 | rows, 17 | nextCursor: cursor < 4 ? cursor + 1 : null, 18 | }; 19 | } 20 | 21 | export function NormalInfiniteScrollSection() { 22 | const { data, error, fetchNextPage, hasNextPage, status } = useInfiniteQuery({ 23 | queryKey: ["normal-infinite-data"], 24 | queryFn: (ctx) => fetchInfiniteData(10, ctx.pageParam), 25 | initialPageParam: 0, 26 | getNextPageParam: (nextPage, pages) => nextPage.nextCursor, 27 | }); 28 | 29 | if (status === "error") return

Error {error.message}

; 30 | if (status === "pending") 31 | return

Loading from client...

; 32 | return ( 33 |
34 |

Normal Infinite Scroll

35 | 42 | {data.pages.map((page, i) => ( 43 | 44 | {page.rows.map((el) => ( 45 |

46 | {el.foo} 47 |

48 | ))} 49 |
50 | ))} 51 |
52 |
53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /src/app/components/prefetch-infinite-scroll-section.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { InfiniteScroller } from "./infinite-scrollers"; 3 | import { getInfiniteDataAction } from "../actions"; 4 | import { useInfiniteQuery } from "@tanstack/react-query"; 5 | import React from "react"; 6 | 7 | export function PreInfiniteScrollSection() { 8 | const { data, error, fetchNextPage, hasNextPage, status } = useInfiniteQuery({ 9 | queryKey: ["prefetch-infinite-data"], 10 | queryFn: (ctx) => getInfiniteDataAction(10, ctx.pageParam), 11 | initialPageParam: 0, 12 | getNextPageParam: (nextPage, pages) => nextPage.nextCursor, 13 | }); 14 | 15 | if (status === "error") return

Error {error.message}

; 16 | if (status === "pending") 17 | return

Loading from client...

; 18 | return ( 19 |
20 |

Prefetch suspense example

21 |

this loads faster

22 | 29 | {data.pages.map((page, i) => ( 30 | 31 | {page.rows.map((el) => ( 32 |

33 | {el.foo} 34 |

35 | ))} 36 |
37 | ))} 38 |
39 |
40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /src/app/components/uni-virtual-infinite-scroll-section.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react-hooks/exhaustive-deps */ 2 | /* eslint-disable react/no-unescaped-entities */ 3 | "use client"; 4 | import React from "react"; 5 | import { useInfiniteQuery } from "@tanstack/react-query"; 6 | import { useVirtualizer } from "@tanstack/react-virtual"; 7 | 8 | async function fetchInfiniteData(limit: number, offset: number = 0) { 9 | const rows = new Array(limit) 10 | .fill(0) 11 | .map((_, i) => `row #${i + offset * limit}`); 12 | 13 | await new Promise((r) => setTimeout(r, 500)); 14 | 15 | return { 16 | rows, 17 | nextOffset: offset + 1, 18 | }; 19 | } 20 | 21 | export function UniVirtualInfiniteScrollSection() { 22 | const PAGE_SIZE = 10000; 23 | const { 24 | status, 25 | data, 26 | error, 27 | isFetchingNextPage, 28 | fetchNextPage, 29 | hasNextPage, 30 | } = useInfiniteQuery({ 31 | queryKey: ["uni-virtual-infinite-data"], 32 | queryFn: (ctx) => fetchInfiniteData(PAGE_SIZE, ctx.pageParam), 33 | getNextPageParam: (lastGroup) => lastGroup.nextOffset, 34 | initialPageParam: 0, 35 | }); 36 | 37 | const parentRef = React.useRef(null); 38 | 39 | const allRows = data ? data.pages.flatMap((d) => d.rows) : []; 40 | 41 | const rowVirtualizer = useVirtualizer({ 42 | count: allRows.length + 1, //plus 1 for bottom loader row 43 | getScrollElement: () => parentRef.current, 44 | estimateSize: () => 100, 45 | overscan: 0, 46 | }); 47 | 48 | React.useEffect(() => { 49 | const [lastItem] = [...rowVirtualizer.getVirtualItems()].reverse(); 50 | 51 | if (!lastItem) return; 52 | 53 | if ( 54 | lastItem.index >= allRows.length - 1 && 55 | hasNextPage && 56 | !isFetchingNextPage 57 | ) { 58 | fetchNextPage(); 59 | } 60 | }, [ 61 | hasNextPage, 62 | fetchNextPage, 63 | allRows.length, 64 | isFetchingNextPage, 65 | rowVirtualizer.getVirtualItems(), 66 | ]); 67 | 68 | if (status === "error") return

Error {error.message}

; 69 | if (status === "pending") 70 | return

Loading from client...

; 71 | return ( 72 |
73 |

Virtual Infinite Scroll

74 |
78 |
84 | {rowVirtualizer.getVirtualItems().map((virtualRow) => { 85 | const isLoaderBottom = virtualRow.index > allRows.length - 1; 86 | const post = allRows[virtualRow.index]; 87 | return ( 88 |
96 | {isLoaderBottom 97 | ? hasNextPage 98 | ? "Loading next page..." 99 | : "No more next page" 100 | : post} 101 |
102 | ); 103 | })} 104 |
105 |
106 |
107 | ); 108 | } 109 | -------------------------------------------------------------------------------- /src/app/components/virtual-observer-section.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | Attempt to use Tanstack Virtual with Intersection Observer, which will reduced complexity and eliminate the use of hacks. 3 | Failed, but I will leave this here incase anyone wants to have a go at it. 4 | */ 5 | 6 | "use client" 7 | import React from "react" 8 | import { useInfiniteQuery } from "@tanstack/react-query" 9 | import { useVirtualizer } from "@tanstack/react-virtual" 10 | 11 | async function fetchInfiniteData(limit: number, offset: number = 0) { 12 | const rows = new Array(limit) 13 | .fill(0) 14 | .map((_, i) => `Async loaded row #${i + offset * limit}`) 15 | 16 | await new Promise((r) => setTimeout(r, 500)) 17 | 18 | return { 19 | rows, 20 | nextOffset: offset < 4 ? offset + 1 : null, 21 | prevOffset: offset > -4 ? offset - 1 : null, 22 | } 23 | } 24 | 25 | export function VirtualObserverSection() { 26 | const PAGE_SIZE = 10 27 | const { 28 | status, 29 | data, 30 | error, 31 | isFetchingNextPage, 32 | isFetchingPreviousPage, 33 | fetchNextPage, 34 | fetchPreviousPage, 35 | hasNextPage, 36 | hasPreviousPage, 37 | } = useInfiniteQuery({ 38 | queryKey: ["virtual-infinite-data"], 39 | queryFn: (ctx) => fetchInfiniteData(PAGE_SIZE, ctx.pageParam), 40 | getNextPageParam: (lastGroup) => lastGroup.nextOffset, 41 | getPreviousPageParam: (firstGroup) => firstGroup.prevOffset, 42 | initialPageParam: 0, 43 | }) 44 | 45 | const nextObserverTarget = React.useRef(null) 46 | const prevObserverTarget = React.useRef(null) 47 | const parentRef = React.useRef(null) 48 | 49 | const allRows = data ? data.pages.flatMap((d) => d.rows) : [] 50 | 51 | const rowVirtualizer = useVirtualizer({ 52 | count: allRows.length + 2, 53 | getScrollElement: () => parentRef.current, 54 | estimateSize: () => 100, 55 | overscan: 0, 56 | }) 57 | 58 | React.useEffect(() => { 59 | const observer = new IntersectionObserver( 60 | (entries) => { 61 | if (entries.at(0)?.isIntersecting) { 62 | if ( 63 | entries.at(0)?.target === nextObserverTarget.current && 64 | hasNextPage && 65 | !isFetchingNextPage 66 | ) { 67 | fetchNextPage() 68 | } else if ( 69 | entries.at(0)?.target === prevObserverTarget.current && 70 | hasPreviousPage && 71 | !isFetchingPreviousPage 72 | ) { 73 | fetchPreviousPage() 74 | } 75 | } 76 | }, 77 | { threshold: 1 } 78 | ) 79 | 80 | if (nextObserverTarget.current) { 81 | observer.observe(nextObserverTarget.current) 82 | } 83 | if (prevObserverTarget.current) { 84 | observer.observe(prevObserverTarget.current) 85 | } 86 | 87 | return () => observer.disconnect() 88 | }, [hasNextPage, hasPreviousPage, isFetchingNextPage, isFetchingPreviousPage]) 89 | 90 | if (status === "error") return

Error {error.message}

91 | if (status === "pending") 92 | return

Loading from client...

93 | return ( 94 |
95 |
96 |
102 | {/*
prev observer
103 | unfortunately, both next and prev observer div will be rendered on top, so this doesn't work 104 | */} 105 | {rowVirtualizer.getVirtualItems().map((virtualRow) => { 106 | const isLoaderTop = virtualRow.index === 0 107 | const isLoaderBottom = virtualRow.index > allRows.length 108 | const post = allRows[virtualRow.index - 1] 109 | 110 | // doing it this way will not trigger intersection observer 111 | // if (isLoaderTop) 112 | // return ( 113 | //
114 | // ... 115 | //
116 | // ) 117 | // else if (isLoaderBottom) 118 | // return
119 | // else 120 | return ( 121 |
129 | {isLoaderTop 130 | ? hasPreviousPage 131 | ? "Loading previous page..." 132 | : "No more previous page" 133 | : isLoaderBottom 134 | ? hasNextPage 135 | ? "Loading next page..." 136 | : "No more next page" 137 | : post} 138 |
139 | ) 140 | })} 141 | {/*
next observer
142 | unfortunately, both next and prev observer div will be rendered on top, so this doesn't work 143 | */} 144 |
145 |
146 |
147 | ) 148 | } 149 | -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Apestein/better-react-infinite-scroll/7d3998fd0db287447a365a3dfcbe775acc195716/src/app/favicon.ico -------------------------------------------------------------------------------- /src/app/fonts/GeistMonoVF.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Apestein/better-react-infinite-scroll/7d3998fd0db287447a365a3dfcbe775acc195716/src/app/fonts/GeistMonoVF.woff -------------------------------------------------------------------------------- /src/app/fonts/GeistVF.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Apestein/better-react-infinite-scroll/7d3998fd0db287447a365a3dfcbe775acc195716/src/app/fonts/GeistVF.woff -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next" 2 | import localFont from "next/font/local" 3 | import "./globals.css" 4 | import Providers from "./providers" 5 | import { ReactQueryDevtools } from "@tanstack/react-query-devtools" 6 | 7 | const geistSans = localFont({ 8 | src: "./fonts/GeistVF.woff", 9 | variable: "--font-geist-sans", 10 | weight: "100 900", 11 | }) 12 | const geistMono = localFont({ 13 | src: "./fonts/GeistMonoVF.woff", 14 | variable: "--font-geist-mono", 15 | weight: "100 900", 16 | }) 17 | 18 | export const metadata: Metadata = { 19 | title: "Create Next App", 20 | description: "Generated by create next app", 21 | } 22 | 23 | export default function RootLayout({ 24 | children, 25 | }: Readonly<{ 26 | children: React.ReactNode 27 | }>) { 28 | return ( 29 | 30 | 33 | 34 | {children} 35 | 36 | 37 | 38 | 39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import React, { Suspense } from "react"; 2 | import { NormalInfiniteScrollSection } from "./components/normal-infinite-scroll-section"; 3 | import { InverseInfiniteScrollSection } from "./components/inverse-infinite-scroll-section"; 4 | import { BiInfiniteScrollSection } from "./components/bi-infinite-scroll-section"; 5 | import { ApiInfiniteScrollSection } from "./components/api-infinite-scroll-section"; 6 | import { PreInfiniteScrollSection } from "./components/prefetch-infinite-scroll-section"; 7 | import { UniVirtualInfiniteScrollSection } from "./components/uni-virtual-infinite-scroll-section"; 8 | import { BiVirtualInfiniteScrollSection } from "./components/bi-virtual-infinite-scroll-section"; 9 | import { 10 | dehydrate, 11 | HydrationBoundary, 12 | QueryClient, 13 | } from "@tanstack/react-query"; 14 | import { getInfiniteDataAction } from "./actions"; 15 | 16 | export default async function Home() { 17 | return ( 18 |
19 |
20 |

21 | Good for small list 22 |

23 | 24 | 25 | 26 |
27 |
28 |

29 | Good for large list 30 |

31 | 32 | 33 |
34 |
35 |

36 | More examples 37 |

38 | 39 | 40 | 41 | 42 |
43 |
44 | ); 45 | } 46 | 47 | async function PrefetchWrapper() { 48 | const queryClient = new QueryClient(); 49 | await queryClient.prefetchInfiniteQuery({ 50 | queryKey: ["prefetch-infinite-data"], 51 | queryFn: (ctx) => getInfiniteDataAction(10, ctx.pageParam), 52 | initialPageParam: 0, 53 | // getNextPageParam: (lastPage, pages) => lastPage.nextCursor, 54 | // pages: 3, //number of pages to prefetch 55 | }); 56 | 57 | return ( 58 | 59 | 60 | 61 | ); 62 | } 63 | -------------------------------------------------------------------------------- /src/app/providers.tsx: -------------------------------------------------------------------------------- 1 | // In Next.js, this file would be called: app/providers.tsx 2 | "use client" 3 | 4 | // Since QueryClientProvider relies on useContext under the hood, we have to put 'use client' on top 5 | import { 6 | isServer, 7 | QueryClient, 8 | QueryClientProvider, 9 | } from "@tanstack/react-query" 10 | 11 | function makeQueryClient() { 12 | return new QueryClient({ 13 | defaultOptions: { 14 | queries: { 15 | // With SSR, we usually want to set some default staleTime 16 | // above 0 to avoid refetching immediately on the client 17 | staleTime: 60 * 1000, 18 | }, 19 | }, 20 | }) 21 | } 22 | 23 | let browserQueryClient: QueryClient | undefined = undefined 24 | 25 | function getQueryClient() { 26 | if (isServer) { 27 | // Server: always make a new query client 28 | return makeQueryClient() 29 | } else { 30 | // Browser: make a new query client if we don't already have one 31 | // This is very important, so we don't re-make a new client if React 32 | // suspends during the initial render. This may not be needed if we 33 | // have a suspense boundary BELOW the creation of the query client 34 | if (!browserQueryClient) browserQueryClient = makeQueryClient() 35 | return browserQueryClient 36 | } 37 | } 38 | 39 | export default function Providers({ children }: { children: React.ReactNode }) { 40 | // NOTE: Avoid useState when initializing the query client if you don't 41 | // have a suspense boundary between this and the code that may 42 | // suspend because React will throw away the client on the initial 43 | // render if it suspends and there is no boundary 44 | const queryClient = getQueryClient() 45 | 46 | return ( 47 | {children} 48 | ) 49 | } 50 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | export default { 4 | content: [ 5 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", 6 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}", 7 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}", 8 | ], 9 | theme: { 10 | extend: { 11 | colors: { 12 | background: "var(--background)", 13 | foreground: "var(--foreground)", 14 | }, 15 | }, 16 | }, 17 | plugins: [], 18 | } satisfies Config; 19 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./src/*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | --------------------------------------------------------------------------------