├── .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 |
--------------------------------------------------------------------------------