diff --git a/examples/react/two-way-infinite-scroll/.gitignore b/examples/react/two-way-infinite-scroll/.gitignore new file mode 100644 index 00000000..d451ff16 --- /dev/null +++ b/examples/react/two-way-infinite-scroll/.gitignore @@ -0,0 +1,5 @@ +node_modules +.DS_Store +dist +dist-ssr +*.local diff --git a/examples/react/two-way-infinite-scroll/README.md b/examples/react/two-way-infinite-scroll/README.md new file mode 100644 index 00000000..b168d3c4 --- /dev/null +++ b/examples/react/two-way-infinite-scroll/README.md @@ -0,0 +1,6 @@ +# Example + +To run this example: + +- `npm install` or `yarn` +- `npm run start` or `yarn start` diff --git a/examples/react/two-way-infinite-scroll/index.html b/examples/react/two-way-infinite-scroll/index.html new file mode 100644 index 00000000..3fc40c93 --- /dev/null +++ b/examples/react/two-way-infinite-scroll/index.html @@ -0,0 +1,13 @@ + + + + + + Vite App + + + +
+ + + diff --git a/examples/react/two-way-infinite-scroll/package.json b/examples/react/two-way-infinite-scroll/package.json new file mode 100644 index 00000000..f9b9e610 --- /dev/null +++ b/examples/react/two-way-infinite-scroll/package.json @@ -0,0 +1,23 @@ +{ + "name": "tanstack-react-virtual-example-two-way-infinite-scroll", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "serve": "vite preview --port 3001", + "start": "vite" + }, + "dependencies": { + "@tanstack/react-query": "^5.66.11", + "@tanstack/react-virtual": "^3.13.2", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "@vitejs/plugin-react": "^4.3.4", + "vite": "^5.4.14" + } +} diff --git a/examples/react/two-way-infinite-scroll/src/index.css b/examples/react/two-way-infinite-scroll/src/index.css new file mode 100644 index 00000000..c46155fa --- /dev/null +++ b/examples/react/two-way-infinite-scroll/src/index.css @@ -0,0 +1,28 @@ +html { + font-family: sans-serif; + font-size: 14px; +} + +body { + padding: 1rem; +} + +.List { + border: 1px solid #e6e4dc; + max-width: 100%; +} + +.ListItemEven, +.ListItemOdd { + display: flex; + align-items: center; + justify-content: center; +} + +.ListItemEven { + background-color: #e6e4dc; +} + +button { + border: 1px solid gray; +} diff --git a/examples/react/two-way-infinite-scroll/src/main.tsx b/examples/react/two-way-infinite-scroll/src/main.tsx new file mode 100644 index 00000000..842d70b1 --- /dev/null +++ b/examples/react/two-way-infinite-scroll/src/main.tsx @@ -0,0 +1,181 @@ +import React, { useState } from 'react' +import ReactDOM from 'react-dom' +import { + InfiniteData, + QueryClient, + QueryClientProvider, + UseInfiniteQueryResult, + useInfiniteQuery, +} from '@tanstack/react-query' +import './index.css' +import { useWindowVirtualizer } from '@tanstack/react-virtual' + +const queryClient = new QueryClient() + +interface Page { + rows: string[] + nextOffset: number +} +async function fetchServerPage( + limit: number, + offset: number = 0, +): Promise { + const rows = new Array(limit) + .fill(0) + .map((e, i) => `Async loaded row #${i + offset * limit}`) + + await new Promise((r) => setTimeout(r, 500)) + + return { rows, nextOffset: offset + 1 } +} + +function App() { + const SCROLL_MARGIN = 200 + const MAX_PAGE_LENGTH = 5 + const ITEM_HEIGHT = 100 + const SENTRY_ITEM_LENGTH = 2 + const [dataPerPagePrefixSum, setDataPerPagePrefixSum] = useState([ + 0, + ]) + const { + status, + data, + error, + isFetchingNextPage, + fetchNextPage, + hasNextPage, + isFetchingPreviousPage, + fetchPreviousPage, + hasPreviousPage, + }: UseInfiniteQueryResult< + InfiniteData, + Error + > = useInfiniteQuery({ + queryKey: ['projects'], + queryFn: async ({ pageParam }) => { + const res = await fetchServerPage(10, pageParam) + if (dataPerPagePrefixSum.length <= pageParam + 1) { + setDataPerPagePrefixSum((prev) => [ + ...prev, + (prev.slice(-1)[0] ?? 0) + res.rows.length, + ]) + } + return res + }, + initialPageParam: 0, + getNextPageParam: (_, __, lastPageParam) => { + return lastPageParam + 1 + }, + getPreviousPageParam: (_, __, firstPageParam) => { + if (firstPageParam <= 0) { + return null + } + return firstPageParam - 1 + }, + maxPages: MAX_PAGE_LENGTH, + }) + + const allRows = data ? data.pages.flatMap((d) => d.rows) : [] + const minPageParam = data?.pageParams[0] ?? 0 + const maxPageParam = data?.pageParams.slice(-1)[0] ?? 0 + const minPageDataLength = dataPerPagePrefixSum[minPageParam] ?? 0 + const maxPageDataLength = dataPerPagePrefixSum[maxPageParam] ?? 0 + const maxDataLength = dataPerPagePrefixSum.slice(-1)[0] ?? 0 + const rowVirtualizer = useWindowVirtualizer({ + count: maxDataLength, + estimateSize: () => ITEM_HEIGHT, + overscan: 5, + scrollMargin: SCROLL_MARGIN, + }) + const virtualItems = rowVirtualizer.getVirtualItems() + React.useEffect(() => { + const firstItemKey = virtualItems[0]?.key as number | undefined + const lastItemKey = virtualItems.slice(-1)[0]?.key as number | undefined + if ( + firstItemKey && + firstItemKey < minPageDataLength + SENTRY_ITEM_LENGTH && + !isFetchingPreviousPage && + hasPreviousPage + ) { + fetchPreviousPage() + } + if ( + lastItemKey && + lastItemKey > maxPageDataLength - SENTRY_ITEM_LENGTH && + !isFetchingNextPage && + hasNextPage + ) { + fetchNextPage() + } + }, [ + virtualItems, + hasNextPage, + hasPreviousPage, + isFetchingNextPage, + isFetchingPreviousPage, + minPageDataLength, + ]) + return ( +
+

+ This code uses React Query and React Virtual to implement an interactive + infinite scroll feature. Its main features include setting maxPages to + limit the maximum number of pages, and fetching data from the server for + the previous or next page when the user moves the scroll up or down. + This saves memory and render costs. +

+ + {status === 'pending' ? ( +

Loading...

+ ) : status === 'error' ? ( + Error: {(error as Error).message} + ) : ( +
+ {virtualItems.map((virtualRow) => { + const post = allRows[virtualRow.index - minPageDataLength] + + return ( +
+ {post == null ? 'loading' : post} +
+ ) + })} +
+ )} +
+ ) +} + +ReactDOM.render( + + + + + , + document.getElementById('root'), +) diff --git a/examples/react/two-way-infinite-scroll/tsconfig.json b/examples/react/two-way-infinite-scroll/tsconfig.json new file mode 100644 index 00000000..87318025 --- /dev/null +++ b/examples/react/two-way-infinite-scroll/tsconfig.json @@ -0,0 +1,25 @@ +{ + "composite": true, + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/examples/react/two-way-infinite-scroll/vite.config.js b/examples/react/two-way-infinite-scroll/vite.config.js new file mode 100644 index 00000000..5a33944a --- /dev/null +++ b/examples/react/two-way-infinite-scroll/vite.config.js @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()], +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c79b5c9d..03213ab3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -829,6 +829,34 @@ importers: specifier: ^5.4.14 version: 5.4.14(@types/node@22.13.7)(less@4.2.2)(sass@1.85.1)(terser@5.39.0) + examples/react/two-way-infinite-scroll: + dependencies: + '@tanstack/react-query': + specifier: ^5.66.11 + version: 5.66.11(react@18.3.1) + '@tanstack/react-virtual': + specifier: ^3.13.2 + version: link:../../../packages/react-virtual + react: + specifier: ^18.3.1 + version: 18.3.1 + react-dom: + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) + devDependencies: + '@types/react': + specifier: ^18.3.18 + version: 18.3.18 + '@types/react-dom': + specifier: ^18.3.5 + version: 18.3.5(@types/react@18.3.18) + '@vitejs/plugin-react': + specifier: ^4.3.4 + version: 4.3.4(vite@5.4.14(@types/node@22.13.7)(less@4.2.2)(sass@1.85.1)(terser@5.39.0)) + vite: + specifier: ^5.4.14 + version: 5.4.14(@types/node@22.13.7)(less@4.2.2)(sass@1.85.1)(terser@5.39.0) + examples/react/variable: dependencies: '@tanstack/react-virtual': @@ -7858,12 +7886,12 @@ snapshots: '@vitejs/plugin-basic-ssl': 1.1.0(vite@5.4.14(@types/node@22.13.7)(less@4.2.0)(sass@1.71.1)(terser@5.29.1)) ansi-colors: 4.1.3 autoprefixer: 10.4.18(postcss@8.4.35) - babel-loader: 9.1.3(@babel/core@7.24.0)(webpack@5.94.0(esbuild@0.20.1)) + babel-loader: 9.1.3(@babel/core@7.24.0)(webpack@5.94.0) babel-plugin-istanbul: 6.1.1 browserslist: 4.24.4 - copy-webpack-plugin: 11.0.0(webpack@5.94.0(esbuild@0.20.1)) + copy-webpack-plugin: 11.0.0(webpack@5.94.0) critters: 0.0.22 - css-loader: 6.10.0(webpack@5.94.0(esbuild@0.20.1)) + css-loader: 6.10.0(webpack@5.94.0) esbuild-wasm: 0.20.1 fast-glob: 3.3.2 http-proxy-middleware: 2.0.7(@types/express@4.17.21) @@ -7872,11 +7900,11 @@ snapshots: jsonc-parser: 3.2.1 karma-source-map-support: 1.4.0 less: 4.2.0 - less-loader: 11.1.0(less@4.2.0)(webpack@5.94.0(esbuild@0.20.1)) - license-webpack-plugin: 4.0.2(webpack@5.94.0(esbuild@0.20.1)) + less-loader: 11.1.0(less@4.2.0)(webpack@5.94.0) + license-webpack-plugin: 4.0.2(webpack@5.94.0) loader-utils: 3.2.1 magic-string: 0.30.8 - mini-css-extract-plugin: 2.8.1(webpack@5.94.0(esbuild@0.20.1)) + mini-css-extract-plugin: 2.8.1(webpack@5.94.0) mrmime: 2.0.0 open: 8.4.2 ora: 5.4.1 @@ -7884,13 +7912,13 @@ snapshots: picomatch: 4.0.1 piscina: 4.4.0 postcss: 8.4.35 - postcss-loader: 8.1.1(postcss@8.4.35)(typescript@5.2.2)(webpack@5.94.0(esbuild@0.20.1)) + postcss-loader: 8.1.1(postcss@8.4.35)(typescript@5.2.2)(webpack@5.94.0) resolve-url-loader: 5.0.0 rxjs: 7.8.1 sass: 1.71.1 - sass-loader: 14.1.1(sass@1.71.1)(webpack@5.94.0(esbuild@0.20.1)) + sass-loader: 14.1.1(sass@1.71.1)(webpack@5.94.0) semver: 7.6.0 - source-map-loader: 5.0.0(webpack@5.94.0(esbuild@0.20.1)) + source-map-loader: 5.0.0(webpack@5.94.0) source-map-support: 0.5.21 terser: 5.29.1 tree-kill: 1.2.2 @@ -7900,10 +7928,10 @@ snapshots: vite: 5.4.14(@types/node@22.13.7)(less@4.2.0)(sass@1.71.1)(terser@5.29.1) watchpack: 2.4.0 webpack: 5.94.0(esbuild@0.20.1) - webpack-dev-middleware: 6.1.2(webpack@5.94.0(esbuild@0.20.1)) + webpack-dev-middleware: 6.1.2(webpack@5.94.0) webpack-dev-server: 4.15.1(webpack@5.94.0(esbuild@0.20.1)) webpack-merge: 5.10.0 - webpack-subresource-integrity: 5.1.0(webpack@5.94.0(esbuild@0.20.1)) + webpack-subresource-integrity: 5.1.0(webpack@5.94.0) optionalDependencies: esbuild: 0.20.1 ng-packagr: 17.3.0(@angular/compiler-cli@17.3.12(@angular/compiler@17.3.12(@angular/core@17.3.12(rxjs@7.8.2)(zone.js@0.15.0)))(typescript@5.2.2))(tslib@2.8.1)(typescript@5.2.2) @@ -10997,7 +11025,7 @@ snapshots: axobject-query@4.1.0: {} - babel-loader@9.1.3(@babel/core@7.24.0)(webpack@5.94.0(esbuild@0.20.1)): + babel-loader@9.1.3(@babel/core@7.24.0)(webpack@5.94.0): dependencies: '@babel/core': 7.24.0 find-cache-dir: 4.0.0 @@ -11354,7 +11382,7 @@ snapshots: dependencies: is-what: 3.14.1 - copy-webpack-plugin@11.0.0(webpack@5.94.0(esbuild@0.20.1)): + copy-webpack-plugin@11.0.0(webpack@5.94.0): dependencies: fast-glob: 3.3.3 glob-parent: 6.0.2 @@ -11395,7 +11423,7 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 - css-loader@6.10.0(webpack@5.94.0(esbuild@0.20.1)): + css-loader@6.10.0(webpack@5.94.0): dependencies: icss-utils: 5.1.0(postcss@8.5.3) postcss: 8.5.3 @@ -12725,7 +12753,7 @@ snapshots: picocolors: 1.1.1 shell-quote: 1.8.2 - less-loader@11.1.0(less@4.2.0)(webpack@5.94.0(esbuild@0.20.1)): + less-loader@11.1.0(less@4.2.0)(webpack@5.94.0): dependencies: klona: 2.0.6 less: 4.2.0 @@ -12764,7 +12792,7 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 - license-webpack-plugin@4.0.2(webpack@5.94.0(esbuild@0.20.1)): + license-webpack-plugin@4.0.2(webpack@5.94.0): dependencies: webpack-sources: 3.2.3 optionalDependencies: @@ -12962,7 +12990,7 @@ snapshots: min-indent@1.0.1: {} - mini-css-extract-plugin@2.8.1(webpack@5.94.0(esbuild@0.20.1)): + mini-css-extract-plugin@2.8.1(webpack@5.94.0): dependencies: schema-utils: 4.3.0 tapable: 2.2.1 @@ -13513,7 +13541,7 @@ snapshots: mlly: 1.7.4 pathe: 2.0.3 - postcss-loader@8.1.1(postcss@8.4.35)(typescript@5.2.2)(webpack@5.94.0(esbuild@0.20.1)): + postcss-loader@8.1.1(postcss@8.4.35)(typescript@5.2.2)(webpack@5.94.0): dependencies: cosmiconfig: 9.0.0(typescript@5.2.2) jiti: 1.21.7 @@ -13870,7 +13898,7 @@ snapshots: safer-buffer@2.1.2: {} - sass-loader@14.1.1(sass@1.71.1)(webpack@5.94.0(esbuild@0.20.1)): + sass-loader@14.1.1(sass@1.71.1)(webpack@5.94.0): dependencies: neo-async: 2.6.2 optionalDependencies: @@ -14141,7 +14169,7 @@ snapshots: source-map-js@1.2.1: {} - source-map-loader@5.0.0(webpack@5.94.0(esbuild@0.20.1)): + source-map-loader@5.0.0(webpack@5.94.0): dependencies: iconv-lite: 0.6.3 source-map-js: 1.2.1 @@ -14760,7 +14788,7 @@ snapshots: schema-utils: 4.3.0 webpack: 5.94.0(esbuild@0.20.1) - webpack-dev-middleware@6.1.2(webpack@5.94.0(esbuild@0.20.1)): + webpack-dev-middleware@6.1.2(webpack@5.94.0): dependencies: colorette: 2.0.20 memfs: 3.5.3 @@ -14818,7 +14846,7 @@ snapshots: webpack-sources@3.2.3: {} - webpack-subresource-integrity@5.1.0(webpack@5.94.0(esbuild@0.20.1)): + webpack-subresource-integrity@5.1.0(webpack@5.94.0): dependencies: typed-assert: 1.0.9 webpack: 5.94.0(esbuild@0.20.1)