diff --git a/docs/config.json b/docs/config.json index e65ba6ad..d9ef673f 100644 --- a/docs/config.json +++ b/docs/config.json @@ -112,6 +112,18 @@ { "label": "react", "children": [ + { + "to": "framework/react/examples/list", + "label": "List" + }, + { + "to": "framework/react/examples/horizontal-list", + "label": "Horizontal List" + }, + { + "to": "framework/react/examples/grid", + "label": "Grid" + }, { "to": "framework/react/examples/fixed", "label": "Fixed" diff --git a/examples/react/grid/index.html b/examples/react/grid/index.html new file mode 100644 index 00000000..e57b08cf --- /dev/null +++ b/examples/react/grid/index.html @@ -0,0 +1,12 @@ + + + + + + Grid Virtualization Example + + +
+ + + diff --git a/examples/react/grid/package.json b/examples/react/grid/package.json new file mode 100644 index 00000000..b291c7a6 --- /dev/null +++ b/examples/react/grid/package.json @@ -0,0 +1,24 @@ +{ + "name": "tanstack-react-virtual-example-grid", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "serve": "vite preview" + }, + "dependencies": { + "@faker-js/faker": "^8.4.1", + "@tanstack/react-virtual": "^3.13.13", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/node": "^24.5.2", + "@types/react": "^18.3.23", + "@types/react-dom": "^18.3.7", + "@vitejs/plugin-react": "^4.5.2", + "typescript": "5.4.5", + "vite": "^5.4.19" + } +} diff --git a/examples/react/grid/src/index.css b/examples/react/grid/src/index.css new file mode 100644 index 00000000..96e65514 --- /dev/null +++ b/examples/react/grid/src/index.css @@ -0,0 +1,77 @@ +*, +*:before, +*:after { + box-sizing: border-box; +} + +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; +} + +.controls { + display: flex; + gap: 0.5rem; + margin-bottom: 1rem; + flex-wrap: wrap; + align-items: center; +} + +.controls label { + display: flex; + align-items: center; + gap: 0.25rem; + cursor: pointer; +} + +.mode-selector { + display: flex; + gap: 0.5rem; + padding: 0.5rem; + background: #f5f5f5; + border-radius: 4px; +} + +.mode-selector label { + padding: 0.25rem 0.5rem; + border-radius: 4px; +} + +.mode-selector input:checked + span { + background: #007bff; + color: white; + padding: 0.25rem 0.5rem; + border-radius: 4px; + margin: -0.25rem -0.5rem; +} + +button { + border: 1px solid gray; + padding: 0.25rem 0.5rem; + background: white; + cursor: pointer; +} + +button:hover { + background: #f0f0f0; +} diff --git a/examples/react/grid/src/main.tsx b/examples/react/grid/src/main.tsx new file mode 100644 index 00000000..f5c28cdd --- /dev/null +++ b/examples/react/grid/src/main.tsx @@ -0,0 +1,261 @@ +import * as React from 'react' +import { createRoot } from 'react-dom/client' +import { faker } from '@faker-js/faker' +import { useVirtualizer } from '@tanstack/react-virtual' + +import './index.css' + +type SizingMode = 'dynamic' | 'fixed' | 'variable' + +const randomNumber = (min: number, max: number) => + faker.number.int({ min, max }) + +const rowCount = 10000 +const columnCount = 10000 + +// Generate cell content for dynamic mode +const generateCellContent = (row: number, col: number) => { + // Use a deterministic seed based on position + faker.seed(row * columnCount + col) + return faker.lorem.words(randomNumber(1, 4)) +} + +// Pre-computed variable sizes for "variable" mode +const variableRowHeights = new Array(rowCount) + .fill(true) + .map(() => 25 + Math.round(Math.random() * 75)) + +const variableColumnWidths = new Array(columnCount) + .fill(true) + .map(() => 75 + Math.round(Math.random() * 125)) + +function Grid() { + const parentRef = React.useRef(null) + const [sizingMode, setSizingMode] = React.useState('dynamic') + + // Row virtualizer + const rowVirtualizer = useVirtualizer({ + count: rowCount, + getScrollElement: () => parentRef.current, + estimateSize: React.useCallback( + (index: number) => { + switch (sizingMode) { + case 'fixed': + return 35 + case 'variable': + return variableRowHeights[index] + case 'dynamic': + default: + return 50 + } + }, + [sizingMode], + ), + overscan: 5, + }) + + // Column virtualizer + const columnVirtualizer = useVirtualizer({ + horizontal: true, + count: columnCount, + getScrollElement: () => parentRef.current, + estimateSize: React.useCallback( + (index: number) => { + switch (sizingMode) { + case 'fixed': + return 100 + case 'variable': + return variableColumnWidths[index] + case 'dynamic': + default: + return 120 + } + }, + [sizingMode], + ), + overscan: 5, + }) + + const virtualRows = rowVirtualizer.getVirtualItems() + const virtualColumns = columnVirtualizer.getVirtualItems() + + return ( +
+
+
+ Sizing Mode: + {(['dynamic', 'fixed', 'variable'] as const).map((mode) => ( + + ))} +
+
+ +
+ + + +
+ +

+ {sizingMode === 'dynamic' && ( + <> + Dynamic mode: Cell sizes are estimated and can vary + based on content. Best for unknown or variable content. + + )} + {sizingMode === 'fixed' && ( + <> + Fixed mode: All cells have the same dimensions + (35px height, 100px width). Best performance for uniform grids. + + )} + {sizingMode === 'variable' && ( + <> + Variable mode: Each row/column has pre-computed + dimensions. Use when sizes are known but vary per row/column. + + )} +

+ +
+
+ {virtualRows.map((virtualRow) => ( + + {virtualColumns.map((virtualColumn) => { + const isEven = + (virtualRow.index + virtualColumn.index) % 2 === 0 + return ( +
+ {sizingMode === 'dynamic' ? ( +
+
+ {virtualRow.index},{virtualColumn.index} +
+
+ {generateCellContent( + virtualRow.index, + virtualColumn.index, + )} +
+
+ ) : ( + `${virtualRow.index}, ${virtualColumn.index}` + )} +
+ ) + })} +
+ ))} +
+
+ +

+ Rendering {virtualRows.length * virtualColumns.length} of{' '} + {(rowCount * columnCount).toLocaleString()} cells ( + {virtualRows.length} rows x {virtualColumns.length} columns) +

+
+ ) +} + +function App() { + return ( +
+

Grid Virtualization

+

+ Efficiently render large 2D grids (spreadsheets, data tables, game + boards) by only rendering visible cells. Both rows and columns are + virtualized simultaneously. +

+ + {process.env.NODE_ENV === 'development' && ( +

+ Note: Running in development mode. Performance will + improve in production builds. +

+ )} +
+ ) +} + +const container = document.getElementById('root')! +const root = createRoot(container) + +root.render( + + + , +) diff --git a/examples/react/grid/tsconfig.json b/examples/react/grid/tsconfig.json new file mode 100644 index 00000000..87318025 --- /dev/null +++ b/examples/react/grid/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/grid/vite.config.js b/examples/react/grid/vite.config.js new file mode 100644 index 00000000..5a33944a --- /dev/null +++ b/examples/react/grid/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/examples/react/horizontal-list/index.html b/examples/react/horizontal-list/index.html new file mode 100644 index 00000000..27b5ad9e --- /dev/null +++ b/examples/react/horizontal-list/index.html @@ -0,0 +1,12 @@ + + + + + + Horizontal List Virtualization Example + + +
+ + + diff --git a/examples/react/horizontal-list/package.json b/examples/react/horizontal-list/package.json new file mode 100644 index 00000000..be440454 --- /dev/null +++ b/examples/react/horizontal-list/package.json @@ -0,0 +1,24 @@ +{ + "name": "tanstack-react-virtual-example-horizontal-list", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "serve": "vite preview" + }, + "dependencies": { + "@faker-js/faker": "^8.4.1", + "@tanstack/react-virtual": "^3.13.13", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/node": "^24.5.2", + "@types/react": "^18.3.23", + "@types/react-dom": "^18.3.7", + "@vitejs/plugin-react": "^4.5.2", + "typescript": "5.4.5", + "vite": "^5.4.19" + } +} diff --git a/examples/react/horizontal-list/src/index.css b/examples/react/horizontal-list/src/index.css new file mode 100644 index 00000000..96e65514 --- /dev/null +++ b/examples/react/horizontal-list/src/index.css @@ -0,0 +1,77 @@ +*, +*:before, +*:after { + box-sizing: border-box; +} + +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; +} + +.controls { + display: flex; + gap: 0.5rem; + margin-bottom: 1rem; + flex-wrap: wrap; + align-items: center; +} + +.controls label { + display: flex; + align-items: center; + gap: 0.25rem; + cursor: pointer; +} + +.mode-selector { + display: flex; + gap: 0.5rem; + padding: 0.5rem; + background: #f5f5f5; + border-radius: 4px; +} + +.mode-selector label { + padding: 0.25rem 0.5rem; + border-radius: 4px; +} + +.mode-selector input:checked + span { + background: #007bff; + color: white; + padding: 0.25rem 0.5rem; + border-radius: 4px; + margin: -0.25rem -0.5rem; +} + +button { + border: 1px solid gray; + padding: 0.25rem 0.5rem; + background: white; + cursor: pointer; +} + +button:hover { + background: #f0f0f0; +} diff --git a/examples/react/horizontal-list/src/main.tsx b/examples/react/horizontal-list/src/main.tsx new file mode 100644 index 00000000..1fcc9460 --- /dev/null +++ b/examples/react/horizontal-list/src/main.tsx @@ -0,0 +1,207 @@ +import * as React from 'react' +import { createRoot } from 'react-dom/client' +import { faker } from '@faker-js/faker' +import { useVirtualizer } from '@tanstack/react-virtual' + +import './index.css' + +type SizingMode = 'dynamic' | 'fixed' | 'variable' + +const randomNumber = (min: number, max: number) => + faker.number.int({ min, max }) + +// Generate data for the horizontal list +const items = new Array(10000) + .fill(true) + .map(() => faker.lorem.words(randomNumber(2, 8))) + +// Pre-computed variable widths for "variable" mode +const variableWidths = new Array(10000) + .fill(true) + .map(() => 75 + Math.round(Math.random() * 150)) + +function HorizontalList() { + const parentRef = React.useRef(null) + const [sizingMode, setSizingMode] = React.useState('dynamic') + + const count = items.length + + // Configure virtualizer based on sizing mode + const virtualizer = useVirtualizer({ + horizontal: true, + count, + getScrollElement: () => parentRef.current, + estimateSize: React.useCallback( + (index: number) => { + switch (sizingMode) { + case 'fixed': + return 100 + case 'variable': + return variableWidths[index] + case 'dynamic': + default: + return 120 + } + }, + [sizingMode], + ), + overscan: 5, + }) + + const virtualItems = virtualizer.getVirtualItems() + + // For dynamic mode, we need to measure elements + const measureElement = sizingMode === 'dynamic' ? virtualizer.measureElement : undefined + + return ( +
+
+
+ Sizing Mode: + {(['dynamic', 'fixed', 'variable'] as const).map((mode) => ( + + ))} +
+
+ +
+ + + +
+ +

+ {sizingMode === 'dynamic' && ( + <> + Dynamic mode: Each item's width is measured at render time. + Content width varies and the virtualizer adapts automatically. + + )} + {sizingMode === 'fixed' && ( + <> + Fixed mode: All items have the same fixed width (100px). + Best performance, use when all items are identical width. + + )} + {sizingMode === 'variable' && ( + <> + Variable mode: Each item has a known but different width. + Widths are pre-computed, not measured at runtime. + + )} +

+ +
+
+ {virtualItems.map((virtualColumn) => ( +
+ {sizingMode === 'dynamic' ? ( +
+
Item {virtualColumn.index}
+
{items[virtualColumn.index]}
+
+ ) : ( + `Column ${virtualColumn.index}` + )} +
+ ))} +
+
+ +

+ Rendering {virtualItems.length} of {count.toLocaleString()} items +

+
+ ) +} + +function App() { + return ( +
+

Horizontal List Virtualization

+

+ Efficiently render large horizontal lists (carousels, timelines, etc.) + by only rendering visible items. Try different sizing modes to see how + they affect behavior. +

+ + {process.env.NODE_ENV === 'development' && ( +

+ Note: Running in development mode. Performance will + improve in production builds. +

+ )} +
+ ) +} + +const container = document.getElementById('root')! +const root = createRoot(container) + +root.render( + + + , +) diff --git a/examples/react/horizontal-list/tsconfig.json b/examples/react/horizontal-list/tsconfig.json new file mode 100644 index 00000000..87318025 --- /dev/null +++ b/examples/react/horizontal-list/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/horizontal-list/vite.config.js b/examples/react/horizontal-list/vite.config.js new file mode 100644 index 00000000..5a33944a --- /dev/null +++ b/examples/react/horizontal-list/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/examples/react/list/index.html b/examples/react/list/index.html new file mode 100644 index 00000000..ad789c92 --- /dev/null +++ b/examples/react/list/index.html @@ -0,0 +1,12 @@ + + + + + + List Virtualization Example + + +
+ + + diff --git a/examples/react/list/package.json b/examples/react/list/package.json new file mode 100644 index 00000000..4d5d7bac --- /dev/null +++ b/examples/react/list/package.json @@ -0,0 +1,24 @@ +{ + "name": "tanstack-react-virtual-example-list", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "serve": "vite preview" + }, + "dependencies": { + "@faker-js/faker": "^8.4.1", + "@tanstack/react-virtual": "^3.13.13", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/node": "^24.5.2", + "@types/react": "^18.3.23", + "@types/react-dom": "^18.3.7", + "@vitejs/plugin-react": "^4.5.2", + "typescript": "5.4.5", + "vite": "^5.4.19" + } +} diff --git a/examples/react/list/src/index.css b/examples/react/list/src/index.css new file mode 100644 index 00000000..96e65514 --- /dev/null +++ b/examples/react/list/src/index.css @@ -0,0 +1,77 @@ +*, +*:before, +*:after { + box-sizing: border-box; +} + +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; +} + +.controls { + display: flex; + gap: 0.5rem; + margin-bottom: 1rem; + flex-wrap: wrap; + align-items: center; +} + +.controls label { + display: flex; + align-items: center; + gap: 0.25rem; + cursor: pointer; +} + +.mode-selector { + display: flex; + gap: 0.5rem; + padding: 0.5rem; + background: #f5f5f5; + border-radius: 4px; +} + +.mode-selector label { + padding: 0.25rem 0.5rem; + border-radius: 4px; +} + +.mode-selector input:checked + span { + background: #007bff; + color: white; + padding: 0.25rem 0.5rem; + border-radius: 4px; + margin: -0.25rem -0.5rem; +} + +button { + border: 1px solid gray; + padding: 0.25rem 0.5rem; + background: white; + cursor: pointer; +} + +button:hover { + background: #f0f0f0; +} diff --git a/examples/react/list/src/main.tsx b/examples/react/list/src/main.tsx new file mode 100644 index 00000000..44e80101 --- /dev/null +++ b/examples/react/list/src/main.tsx @@ -0,0 +1,202 @@ +import * as React from 'react' +import { createRoot } from 'react-dom/client' +import { faker } from '@faker-js/faker' +import { useVirtualizer } from '@tanstack/react-virtual' + +import './index.css' + +type SizingMode = 'dynamic' | 'fixed' | 'variable' + +const randomNumber = (min: number, max: number) => + faker.number.int({ min, max }) + +// Generate data for the list +const sentences = new Array(10000) + .fill(true) + .map(() => faker.lorem.sentence(randomNumber(20, 70))) + +// Pre-computed variable sizes for "variable" mode +const variableSizes = new Array(10000) + .fill(true) + .map(() => 25 + Math.round(Math.random() * 100)) + +function List() { + const parentRef = React.useRef(null) + const [sizingMode, setSizingMode] = React.useState('dynamic') + + const count = sentences.length + + // Configure virtualizer based on sizing mode + const virtualizer = useVirtualizer({ + count, + getScrollElement: () => parentRef.current, + estimateSize: React.useCallback( + (index: number) => { + switch (sizingMode) { + case 'fixed': + return 35 + case 'variable': + return variableSizes[index] + case 'dynamic': + default: + return 45 + } + }, + [sizingMode], + ), + overscan: 5, + }) + + const items = virtualizer.getVirtualItems() + + // For dynamic mode, we need to measure elements + const measureElement = sizingMode === 'dynamic' ? virtualizer.measureElement : undefined + + return ( +
+
+
+ Sizing Mode: + {(['dynamic', 'fixed', 'variable'] as const).map((mode) => ( + + ))} +
+
+ +
+ + + +
+ +

+ {sizingMode === 'dynamic' && ( + <> + Dynamic mode: Each item's height is measured at render time. + Content can vary and the virtualizer adapts automatically. + + )} + {sizingMode === 'fixed' && ( + <> + Fixed mode: All items have the same fixed height (35px). + Best performance, use when all items are identical height. + + )} + {sizingMode === 'variable' && ( + <> + Variable mode: Each item has a known but different height. + Heights are pre-computed, not measured at runtime. + + )} +

+ +
+
+
+ {items.map((virtualRow) => ( +
+ {sizingMode === 'dynamic' ? ( +
+
Row {virtualRow.index}
+
{sentences[virtualRow.index]}
+
+ ) : ( +
Row {virtualRow.index}
+ )} +
+ ))} +
+
+
+ +

+ Rendering {items.length} of {count.toLocaleString()} items +

+
+ ) +} + +function App() { + return ( +
+

List Virtualization

+

+ Efficiently render large vertical lists by only rendering visible items. + Try different sizing modes to see how they affect behavior. +

+ + {process.env.NODE_ENV === 'development' && ( +

+ Note: Running in development mode. Performance will + improve in production builds. +

+ )} +
+ ) +} + +const container = document.getElementById('root')! +const root = createRoot(container) + +root.render( + + + , +) diff --git a/examples/react/list/tsconfig.json b/examples/react/list/tsconfig.json new file mode 100644 index 00000000..87318025 --- /dev/null +++ b/examples/react/list/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/list/vite.config.js b/examples/react/list/vite.config.js new file mode 100644 index 00000000..5a33944a --- /dev/null +++ b/examples/react/list/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()], +})