feat(table): virtualization (#4285)

* feat: baseline virtualization for table

* merge branch canary

* fix: table layout

* fix: calc header height w layouteffect to offset padding

* Merge branch 'canary' into feat/eng-1633-virtualization-for-table

* chore: remove unused files and comments

* chore: add missing package

* feat: add shouldVirtualize conditional to render virtualized-table

* feat: update docs for table

* feat: use wrapper to support theme styles

* chore: add changeset

* chore(changeset): update package name

* chore(deps): pnpm-lock.yaml

* fix(table): outdated package name

* chore(changeset): add issue number

* fix(deps): keep the version consistent with other components

* fix(table): incorrect displayName

* refactor(table): use VirtualizedTemplate

* chore(deps): bump `@tanstack/react-virtua`

* chore(deps): typecheck issue

* fix(table): do not use any type

* chore: remove auto virtualization

---------

Co-authored-by: աӄա <wingkwong.code@gmail.com>
Co-authored-by: Junior Garcia <jrgarciadev@gmail.com>
This commit is contained in:
Vincentius Roger Kuswara 2025-02-11 20:56:08 +08:00 committed by GitHub
parent a1cc378887
commit fbc361c3b1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 855 additions and 392 deletions

View File

@ -0,0 +1,5 @@
---
"@heroui/table": patch
---
Virtualization support added to Table component (#3697)

View File

@ -423,7 +423,8 @@
"key": "table",
"title": "Table",
"keywords": "table, data display, grid, spreadsheet",
"path": "/docs/components/table.mdx"
"path": "/docs/components/table.mdx",
"updated": true
},
{
"key": "tabs",

View File

@ -19,6 +19,10 @@ import asyncPagination from "./async-pagination";
import infinitePagination from "./infinite-pagination";
import useCase from "./use-case";
import customStyles from "./custom-styles";
import virtualization from "./virtualization";
import virtualizationCustomItemHeight from "./virtualization-custom-row-height";
import virtualizationCustomMaxTableHeight from "./virtualization-custom-max-table-height";
import virtualizationTenThousand from "./virtualization-ten-thousand";
export const tableContent = {
usage,
@ -42,4 +46,8 @@ export const tableContent = {
infinitePagination,
useCase,
customStyles,
virtualization,
virtualizationCustomItemHeight,
virtualizationCustomMaxTableHeight,
virtualizationTenThousand,
};

View File

@ -0,0 +1,37 @@
import {Table, TableBody, TableCell, TableColumn, TableHeader, TableRow} from "@nextui-org/react";
function generateRows(count) {
return Array.from({length: count}, (_, index) => ({
key: index.toString(),
name: `Item ${index + 1}`,
value: `Value ${index + 1}`,
}));
}
export default function App() {
const rows = generateRows(500);
const columns = [
{key: "name", label: "Name"},
{key: "value", label: "Value"},
];
return (
<Table
isVirtualized
aria-label="Example of virtualized table with a large dataset"
maxTableHeight={300}
rowHeight={40}
>
<TableHeader columns={columns}>
{(column) => <TableColumn key={column.key}>{column.label}</TableColumn>}
</TableHeader>
<TableBody items={rows}>
{(item) => (
<TableRow key={item.key}>
{(columnKey) => <TableCell>{item[columnKey]}</TableCell>}
</TableRow>
)}
</TableBody>
</Table>
);
}

View File

@ -0,0 +1,9 @@
import App from "./virtualization-custom-max-table-height.raw.jsx?raw";
const react = {
"/App.jsx": App,
};
export default {
...react,
};

View File

@ -0,0 +1,37 @@
import {Table, TableBody, TableCell, TableColumn, TableHeader, TableRow} from "@nextui-org/react";
function generateRows(count) {
return Array.from({length: count}, (_, index) => ({
key: index.toString(),
name: `Item ${index + 1}`,
value: `Value ${index + 1}`,
}));
}
export default function App() {
const rows = generateRows(500);
const columns = [
{key: "name", label: "Name"},
{key: "value", label: "Value"},
];
return (
<Table
isVirtualized
aria-label="Example of virtualized table with a large dataset"
maxTableHeight={500}
rowHeight={70}
>
<TableHeader columns={columns}>
{(column) => <TableColumn key={column.key}>{column.label}</TableColumn>}
</TableHeader>
<TableBody items={rows}>
{(item) => (
<TableRow key={item.key}>
{(columnKey) => <TableCell>{item[columnKey]}</TableCell>}
</TableRow>
)}
</TableBody>
</Table>
);
}

View File

@ -0,0 +1,9 @@
import App from "./virtualization-custom-row-height.raw.jsx?raw";
const react = {
"/App.jsx": App,
};
export default {
...react,
};

View File

@ -0,0 +1,37 @@
import {Table, TableBody, TableCell, TableColumn, TableHeader, TableRow} from "@nextui-org/react";
function generateRows(count) {
return Array.from({length: count}, (_, index) => ({
key: index.toString(),
name: `Item ${index + 1}`,
value: `Value ${index + 1}`,
}));
}
export default function App() {
const rows = generateRows(10000);
const columns = [
{key: "name", label: "Name"},
{key: "value", label: "Value"},
];
return (
<Table
isVirtualized
aria-label="Example of virtualized table with a large dataset"
maxTableHeight={500}
rowHeight={40}
>
<TableHeader columns={columns}>
{(column) => <TableColumn key={column.key}>{column.label}</TableColumn>}
</TableHeader>
<TableBody items={rows}>
{(item) => (
<TableRow key={item.key}>
{(columnKey) => <TableCell>{item[columnKey]}</TableCell>}
</TableRow>
)}
</TableBody>
</Table>
);
}

View File

@ -0,0 +1,9 @@
import App from "./virtualization-ten-thousand.raw.jsx?raw";
const react = {
"/App.jsx": App,
};
export default {
...react,
};

View File

@ -0,0 +1,37 @@
import {Table, TableBody, TableCell, TableColumn, TableHeader, TableRow} from "@nextui-org/react";
function generateRows(count) {
return Array.from({length: count}, (_, index) => ({
key: index.toString(),
name: `Item ${index + 1}`,
value: `Value ${index + 1}`,
}));
}
export default function App() {
const rows = generateRows(500);
const columns = [
{key: "name", label: "Name"},
{key: "value", label: "Value"},
];
return (
<Table
isVirtualized
aria-label="Example of virtualized table with a large dataset"
maxTableHeight={500}
rowHeight={40}
>
<TableHeader columns={columns}>
{(column) => <TableColumn key={column.key}>{column.label}</TableColumn>}
</TableHeader>
<TableBody items={rows}>
{(item) => (
<TableRow key={item.key}>
{(columnKey) => <TableCell>{item[columnKey]}</TableCell>}
</TableRow>
)}
</TableBody>
</Table>
);
}

View File

@ -0,0 +1,9 @@
import App from "./virtualization.raw.jsx?raw";
const react = {
"/App.jsx": App,
};
export default {
...react,
};

View File

@ -317,6 +317,36 @@ import { useAsyncList } from "@react-stately/data";
iframeSrc="/examples/table/infinite-pagination"
/>
### Virtualization
Table supports virtualization, which allows efficient rendering of large lists by only rendering items that are visible in the viewport. You can enable virtualization by setting the `isVirtualized` prop to `true`.
<CodeDemo
title="Virtualization"
files={tableContent.virtualization}
/>
> **Note**: The virtualization strategy is based on the [@tanstack/react-virtual](https://tanstack.com/virtual/latest) package, which provides efficient rendering of large lists by only rendering items that are visible in the viewport.
#### Ten Thousand Items
Here's an example of using virtualization with 10,000 items.
<CodeDemo title="Ten Thousand Items" files={tableContent.virtualizationTenThousand} />
#### Max Table Height
The `maxTableHeight` prop is used to set the maximum height of the table. This is required when using virtualization. By default, it's set to `600`.
<CodeDemo title="Max Table Height" files={tableContent.virtualizationCustomMaxTableHeight} />
#### Custom Row Height
The `rowHeight` prop is used to set the height of each row in the table. This is required when using virtualization. By default, it's set to `40`.
<CodeDemo title="Custom Row Height" files={tableContent.virtualizationCustomItemHeight} />
### Use Case Example
When creating a table, you usually need core functionalities like sorting, pagination, and filtering. In the
@ -456,6 +486,24 @@ You can customize the `Table` component by passing custom Tailwind CSS classes t
type: "none | sm | md | lg",
description: "The shadow size of the table.",
default: "sm"
},
{
attribute: "maxTableHeight",
type: "number",
description: "The maximum height of the table in pixels. Required when using virtualization.",
default: 600
},
{
attribute: "rowHeight",
type: "number",
description: "The fixed height of each row item in pixels. Required when using virtualization.",
default: 40
},
{
attribute: "isVirtualized",
type: "boolean",
description: "Whether to enable virtualization. By default, it's enabled when the number of items exceeds 50.",
default: "undefined"
},
{
attribute: "hideHeader",

View File

@ -53,7 +53,7 @@
"@react-stately/tree": "3.8.7",
"@rehooks/local-storage": "^2.4.5",
"@stackblitz/sdk": "^1.11.0",
"@tanstack/react-virtual": "3.11.2",
"@tanstack/react-virtual": "3.11.3",
"@vercel/analytics": "^1.4.1",
"canvas-confetti": "^1.9.2",
"clsx": "^1.2.1",
@ -136,7 +136,7 @@
"prettier": "^2.7.1",
"tailwindcss": "3.4.14",
"tsx": "^3.8.2",
"typescript": "^5.5.0",
"typescript": "^5.7.3",
"uuid": "^8.3.2"
},
"pnpm": {

View File

@ -133,7 +133,7 @@
"tsup": "8.3.5",
"tsx": "^4.19.2",
"turbo": "1.6.3",
"typescript": "^5.5.0",
"typescript": "^5.7.3",
"webpack": "^5.53.0",
"webpack-bundle-analyzer": "^4.4.2",
"webpack-cli": "^3.3.11",

View File

@ -45,7 +45,7 @@
"@heroui/react-utils": "workspace:*",
"@heroui/shared-utils": "workspace:*",
"@heroui/use-is-mobile": "workspace:*",
"@tanstack/react-virtual": "3.11.2",
"@tanstack/react-virtual": "3.11.3",
"@react-aria/utils": "3.27.0",
"@react-aria/listbox": "3.14.0",
"@react-stately/list": "3.11.2",

View File

@ -59,7 +59,7 @@
"@react-aria/utils": "3.27.0",
"@react-aria/visually-hidden": "3.8.19",
"@react-types/shared": "3.27.0",
"@tanstack/react-virtual": "3.11.2"
"@tanstack/react-virtual": "3.11.3"
},
"devDependencies": {
"@heroui/avatar": "workspace:*",

View File

@ -53,7 +53,8 @@
"@react-stately/table": "3.13.1",
"@react-stately/virtualizer": "4.2.1",
"@react-types/grid": "3.2.11",
"@react-types/table": "3.10.4"
"@react-types/table": "3.10.4",
"@tanstack/react-virtual": "3.11.3"
},
"devDependencies": {
"@heroui/theme": "workspace:*",

View File

@ -1,4 +1,6 @@
import {forwardRef, HTMLHeroUIProps} from "@heroui/system";
import type {HTMLHeroUIProps} from "@heroui/system";
import {forwardRef} from "react";
import {useDOMRef} from "@heroui/react-utils";
import {clsx} from "@heroui/shared-utils";
import {useTableRowGroup} from "@react-aria/table";
@ -11,10 +13,11 @@ export interface TableRowGroupProps extends HTMLHeroUIProps<"thead"> {
classNames?: ValuesType["classNames"];
}
const TableRowGroup = forwardRef<"thead", TableRowGroupProps>((props, ref) => {
const TableRowGroup = forwardRef<HTMLTableSectionElement, TableRowGroupProps>((props, ref) => {
const {as, className, children, slots, classNames, ...otherProps} = props;
const Component = as || "thead";
const domRef = useDOMRef(ref);
const {rowGroupProps} = useTableRowGroup();

View File

@ -3,13 +3,19 @@ import {Spacer} from "@heroui/spacer";
import {forwardRef} from "@heroui/system";
import {UseTableProps, useTable} from "./use-table";
import VirtualizedTable from "./virtualized-table";
import TableRowGroup from "./table-row-group";
import TableHeaderRow from "./table-header-row";
import TableColumnHeader from "./table-column-header";
import TableSelectAllCheckbox from "./table-select-all-checkbox";
import TableBody from "./table-body";
export interface TableProps extends Omit<UseTableProps, "isSelectable" | "isMultiSelectable"> {}
export interface TableProps<T = object>
extends Omit<UseTableProps<T>, "isSelectable" | "isMultiSelectable"> {
isVirtualized?: boolean;
rowHeight?: number;
maxTableHeight?: number;
}
const Table = forwardRef<"table", TableProps>((props, ref) => {
const {
@ -30,6 +36,14 @@ const Table = forwardRef<"table", TableProps>((props, ref) => {
ref,
});
const {isVirtualized, rowHeight = 40, maxTableHeight = 600} = props;
// TODO: remove this after testing the table on production, users can only
// enable the virtualization if the passed `isVirtualized` prop is true
// const shouldVirtualize = values.collection.size > 50 || isVirtualized;
const shouldVirtualize = isVirtualized;
const Wrapper = useCallback(
({children}: {children: JSX.Element}) => {
if (removeWrapper) {
@ -41,6 +55,17 @@ const Table = forwardRef<"table", TableProps>((props, ref) => {
[removeWrapper, getWrapperProps],
);
if (shouldVirtualize) {
return (
<VirtualizedTable
{...(props as TableProps)}
ref={ref}
maxTableHeight={maxTableHeight}
rowHeight={rowHeight}
/>
);
}
return (
<div {...getBaseProps()}>
{topContentPlacement === "outside" && topContent}

View File

@ -0,0 +1,169 @@
import {forwardRef, HTMLHeroUIProps} from "@heroui/system";
import {useDOMRef} from "@heroui/react-utils";
import {clsx, dataAttr} from "@heroui/shared-utils";
import {useTableRowGroup} from "@react-aria/table";
import {filterDOMProps} from "@heroui/react-utils";
import {mergeProps} from "@react-aria/utils";
import {Virtualizer} from "@tanstack/react-virtual";
import TableRow from "./table-row";
import TableCell from "./table-cell";
import TableCheckboxCell from "./table-checkbox-cell";
import {ValuesType} from "./use-table";
// @internal
export interface VirtualizedTableBodyProps extends HTMLHeroUIProps<"tbody"> {
slots: ValuesType["slots"];
collection: ValuesType["collection"];
state: ValuesType["state"];
isSelectable: ValuesType["isSelectable"];
color: ValuesType["color"];
disableAnimation: ValuesType["disableAnimation"];
checkboxesProps: ValuesType["checkboxesProps"];
selectionMode: ValuesType["selectionMode"];
classNames?: ValuesType["classNames"];
rowVirtualizer: Virtualizer<any, Element>;
}
const VirtualizedTableBody = forwardRef<"tbody", VirtualizedTableBodyProps>((props, ref) => {
const {
as,
className,
slots,
state,
collection,
isSelectable,
color,
disableAnimation,
checkboxesProps,
selectionMode,
classNames,
rowVirtualizer,
...otherProps
} = props;
const Component = as || "tbody";
const shouldFilterDOMProps = typeof Component === "string";
const domRef = useDOMRef(ref);
const {rowGroupProps} = useTableRowGroup();
const tbodyStyles = clsx(classNames?.tbody, className);
const bodyProps = collection?.body.props;
const isLoading =
bodyProps?.isLoading ||
bodyProps?.loadingState === "loading" ||
bodyProps?.loadingState === "loadingMore";
const items = [...collection.body.childNodes];
const virtualItems = rowVirtualizer.getVirtualItems();
let emptyContent;
let loadingContent;
if (collection.size === 0 && bodyProps.emptyContent) {
emptyContent = (
<tr role="row">
<td
className={slots?.emptyWrapper({class: classNames?.emptyWrapper})}
colSpan={collection.columnCount}
role="gridcell"
>
{!isLoading && bodyProps.emptyContent}
</td>
</tr>
);
}
if (isLoading && bodyProps.loadingContent) {
loadingContent = (
<tr role="row">
<td
className={slots?.loadingWrapper({class: classNames?.loadingWrapper})}
colSpan={collection.columnCount}
role="gridcell"
>
{bodyProps.loadingContent}
</td>
{!emptyContent && collection.size === 0 ? (
<td className={slots?.emptyWrapper({class: classNames?.emptyWrapper})} />
) : null}
</tr>
);
}
return (
<Component
ref={domRef}
{...mergeProps(
rowGroupProps,
filterDOMProps(bodyProps, {
enabled: shouldFilterDOMProps,
}),
otherProps,
)}
className={slots.tbody?.({class: tbodyStyles})}
data-empty={dataAttr(collection.size === 0)}
data-loading={dataAttr(isLoading)}
>
{virtualItems.map((virtualRow, index) => {
const row = items[virtualRow.index];
if (!row) {
return null;
}
return (
<TableRow
key={String(row.key)}
classNames={classNames}
isSelectable={isSelectable}
node={row}
slots={slots}
state={state}
style={{
transform: `translateY(${virtualRow.start - index * virtualRow.size}px)`,
height: `${virtualRow.size}px`,
}}
>
{[...row.childNodes].map((cell) =>
cell.props.isSelectionCell ? (
<TableCheckboxCell
key={String(cell.key)}
checkboxesProps={checkboxesProps}
classNames={classNames}
color={color}
disableAnimation={disableAnimation}
node={cell}
rowKey={row.key}
selectionMode={selectionMode}
slots={slots}
state={state}
/>
) : (
<TableCell
key={String(cell.key)}
classNames={classNames}
node={cell}
rowKey={row.key}
slots={slots}
state={state}
/>
),
)}
</TableRow>
);
})}
{loadingContent}
{emptyContent}
</Component>
);
});
VirtualizedTableBody.displayName = "HeroUI.VirtualizedTableBody";
export default VirtualizedTableBody;

View File

@ -0,0 +1,155 @@
import {useCallback, useLayoutEffect, useRef, useState} from "react";
import {Spacer} from "@heroui/spacer";
import {forwardRef} from "@heroui/system";
import {useVirtualizer} from "@tanstack/react-virtual";
import {UseTableProps, useTable} from "./use-table";
import TableRowGroup from "./table-row-group";
import TableHeaderRow from "./table-header-row";
import TableColumnHeader from "./table-column-header";
import TableSelectAllCheckbox from "./table-select-all-checkbox";
import VirtualizedTableBody from "./virtualized-table-body";
export interface TableProps<T = object>
extends Omit<UseTableProps<T>, "isSelectable" | "isMultiSelectable"> {
isVirtualized?: boolean;
rowHeight?: number;
maxTableHeight?: number;
}
const VirtualizedTable = forwardRef<"table", TableProps>((props, ref) => {
const {
BaseComponent,
Component,
collection,
values,
topContent,
topContentPlacement,
bottomContentPlacement,
bottomContent,
removeWrapper,
getBaseProps,
getWrapperProps,
getTableProps,
} = useTable({
...props,
ref,
});
const {rowHeight = 40, maxTableHeight = 600} = props;
const Wrapper = useCallback(
({children}: {children: JSX.Element}) => {
if (removeWrapper) {
return children;
}
return (
<BaseComponent
{...getWrapperProps()}
ref={parentRef}
/* Display must be block to maintain the scroll "progress" */
style={{height: maxTableHeight, display: "block"}}
>
{children}
</BaseComponent>
);
},
[removeWrapper, getWrapperProps, maxTableHeight],
);
const items = [...collection.body.childNodes];
const count = items.length;
const parentRef = useRef(null);
const [headerHeight, setHeaderHeight] = useState(0);
const headerRef = useRef<HTMLTableSectionElement>(null);
useLayoutEffect(() => {
if (headerRef.current) {
setHeaderHeight(headerRef.current.getBoundingClientRect().height);
}
}, [headerRef]);
const rowVirtualizer = useVirtualizer({
count,
getScrollElement: () => parentRef.current,
estimateSize: () => rowHeight,
overscan: 5,
});
return (
<div {...getBaseProps()}>
{/* We need to add p-1 to make the shadow-sm visible */}
<Wrapper>
<>
{topContentPlacement === "outside" && topContent}
<div style={{height: `calc(${rowVirtualizer.getTotalSize() + headerHeight}px)`}}>
<>
{topContentPlacement === "inside" && topContent}
<Component {...getTableProps()}>
<TableRowGroup ref={headerRef} classNames={values.classNames} slots={values.slots}>
{collection.headerRows.map((headerRow) => (
<TableHeaderRow
key={headerRow?.key}
classNames={values.classNames}
node={headerRow}
slots={values.slots}
state={values.state}
>
{[...headerRow.childNodes].map((column) =>
column?.props?.isSelectionCell ? (
<TableSelectAllCheckbox
key={column?.key}
checkboxesProps={values.checkboxesProps}
classNames={values.classNames}
color={values.color}
disableAnimation={values.disableAnimation}
node={column}
selectionMode={values.selectionMode}
slots={values.slots}
state={values.state}
/>
) : (
<TableColumnHeader
key={column?.key}
classNames={values.classNames}
node={column}
slots={values.slots}
state={values.state}
/>
),
)}
</TableHeaderRow>
))}
<Spacer as="tr" tabIndex={-1} y={1} />
</TableRowGroup>
<VirtualizedTableBody
checkboxesProps={values.checkboxesProps}
classNames={values.classNames}
collection={values.collection}
color={values.color}
disableAnimation={values.disableAnimation}
isSelectable={values.isSelectable}
rowVirtualizer={rowVirtualizer}
selectionMode={values.selectionMode}
slots={values.slots}
state={values.state}
/>
</Component>
{bottomContentPlacement === "inside" && bottomContent}
</>
</div>
{bottomContentPlacement === "outside" && bottomContent}
</>
</Wrapper>
</div>
);
});
VirtualizedTable.displayName = "HeroUI.VirtualizedTable";
export default VirtualizedTable;

View File

@ -120,6 +120,14 @@ type SWCharacter = {
birth_year: string;
};
const generateRows = (rowCount: number) => {
return Array.from({length: rowCount}, (_, index) => ({
key: index.toString(),
name: `Item ${index + 1}`,
value: `Value ${index + 1}`,
}));
};
const StaticTemplate = (args: TableProps) => (
<Table aria-label="Example static collection table" {...args}>
<TableHeader>
@ -912,6 +920,38 @@ const InfinitePaginationTemplate = (args: TableProps) => {
);
};
const VirtualizedTemplate = (args: TableProps & {rowCount: number}) => {
const {rowCount, ...rest} = args;
const rows = generateRows(rowCount);
const columns = [
{key: "name", label: "Name"},
{key: "value", label: "Value"},
];
return (
<div>
<Table
aria-label="Example of virtualized table with a large dataset"
{...rest}
isVirtualized
maxTableHeight={300}
rowHeight={40}
>
<TableHeader columns={columns}>
{(column) => <TableColumn key={column.key}>{column.label}</TableColumn>}
</TableHeader>
<TableBody items={rows}>
{(item) => (
<TableRow key={item.key}>
{(columnKey) => <TableCell>{item[columnKey]}</TableCell>}
</TableRow>
)}
</TableBody>
</Table>
</div>
);
};
export const Default = {
render: StaticTemplate,
@ -1110,3 +1150,21 @@ export const TableWithSwitch = {
selectionMode: "multiple",
},
};
export const Virtualized = {
render: VirtualizedTemplate,
args: {
...defaultProps,
className: "max-w-3xl",
rowCount: 500,
},
};
export const TenThousandRows = {
render: VirtualizedTemplate,
args: {
...defaultProps,
className: "max-w-3xl",
rowCount: 10000,
},
};

570
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff