How to Build a Sortable Virtualized Table in React with HeroUI v3
My React application was choking on a table with 5,000 rows. The browser would freeze for seconds whenever I tried to sort or scroll. I needed a solution that could handle large datasets without sacrificing user experience.
After evaluating several options, I discovered HeroUI v3’s Table component with built-in sorting and React Aria Virtualizer integration. Here’s how I built a performant sortable table that handles thousands of rows smoothly.
The Problem: DOM Overload
When you render 5,000 table rows directly to the DOM, the browser struggles. Each row creates multiple DOM elements, and the browser has to:
- Calculate layout for all 5,000 rows
- Keep all elements in memory
- Re-render everything on any state change
5,000 rows × 6 cells × (element + text node + event listeners)= ~60,000+ DOM nodes= Frozen browserThe solution is virtualization: only render what’s visible.
HeroUI v3 Table Basics
HeroUI v3 uses a compound component pattern with dot notation:
import { Table } from '@heroui/react';
function BasicTable({ data }) { return ( <Table> <Table.ScrollContainer> <Table.Content aria-label="Users table"> <Table.Header> <Table.Column>Name</Table.Column> <Table.Column>Email</Table.Column> <Table.Column>Role</Table.Column> </Table.Header> <Table.Body> {data.map((user) => ( <Table.Row key={user.id}> <Table.Cell>{user.name}</Table.Cell> <Table.Cell>{user.email}</Table.Cell> <Table.Cell>{user.role}</Table.Cell> </Table.Row> ))} </Table.Body> </Table.Content> </Table.ScrollContainer> </Table> );}Key points:
Table.ScrollContainerprovides scrollable wrapperTable.Contentrequiresaria-labelfor accessibility- Always use unique
keyon eachTable.Row
Adding Sorting
I initially thought sorting would require a third-party library. HeroUI makes it straightforward with two parts: marking sortable columns and managing sort state.
"use client";
import { useState, useMemo } from 'react';import { Table } from '@heroui/react';
function SortableTable({ data }) { const [sortDescriptor, setSortDescriptor] = useState({ column: 'name', direction: 'ascending' });
const sortedData = useMemo(() => { const items = [...data]; items.sort((a, b) => { const first = a[sortDescriptor.column]; const second = b[sortDescriptor.column];
let cmp = first < second ? -1 : 1; return sortDescriptor.direction === 'descending' ? -cmp : cmp; }); return items; }, [data, sortDescriptor]);
return ( <Table> <Table.ScrollContainer> <Table.Content aria-label="Sortable users table" sortDescriptor={sortDescriptor} onSortChange={setSortDescriptor} > <Table.Header> <Table.Column allowsSorting>Name</Table.Column> <Table.Column allowsSorting>Email</Table.Column> <Table.Column allowsSorting>Role</Table.Column> </Table.Header> <Table.Body> {sortedData.map((user) => ( <Table.Row key={user.id}> <Table.Cell>{user.name}</Table.Cell> <Table.Cell>{user.email}</Table.Cell> <Table.Cell>{user.role}</Table.Cell> </Table.Row> ))} </Table.Body> </Table.Content> </Table.ScrollContainer> </Table> );}Sort API Reference
| Prop | Component | Type | Description |
|---|---|---|---|
allowsSorting | Table.Column | boolean | Enables sorting UI for column |
sortDescriptor | Table.Content | SortDescriptor | Current sort state |
onSortChange | Table.Content | (descriptor) => void | Sort change handler |
Virtualization for Large Datasets
This is where the performance magic happens. Virtualization renders only visible items to the DOM and reuses elements as users scroll.
import { useMemo } from 'react';import { Table } from '@heroui/react';import { Virtualizer, TableLayout } from 'react-aria-components';
function VirtualizedTable({ data }) { const layout = useMemo(() => new TableLayout({ estimatedRowHeight: 48, columnWidths: [200, 250, 150] }), []);
return ( <div style={{ height: '600px', overflow: 'auto' }}> <Virtualizer layout={layout} estimatedItemSize={48} > <Table> <Table.Content aria-label="Virtualized table"> <Table.Header> <Table.Column>Name</Table.Column> <Table.Column>Email</Table.Column> <Table.Column>Role</Table.Column> </Table.Header> <Table.Body> {data.map((user) => ( <Table.Row key={user.id}> <Table.Cell>{user.name}</Table.Cell> <Table.Cell>{user.email}</Table.Cell> <Table.Cell>{user.role}</Table.Cell> </Table.Row> ))} </Table.Body> </Table.Content> </Table> </Virtualizer> </div> );}Performance Lessons Learned
I made several mistakes before getting virtualization right:
- Forgot to set container height - Without explicit height, all items render to DOM, negating virtualization
- Skipped
estimatedRowHeight- Scrollbar was inaccurate, causing jumpy behavior - Used array index as key - Rows wouldn’t update correctly when sorting
The fix:
// WRONG: No height, no virtualization benefit<div> <Virtualizer>...</Virtualizer></div>
// CORRECT: Explicit height enables virtualization<div style={{ height: '600px', overflow: 'auto' }}> <Virtualizer estimatedItemSize={48}>...</Virtualizer></div>Combining Sorting and Virtualization
For production use, I needed both features together:
"use client";
import { useState, useMemo } from 'react';import { Table } from '@heroui/react';
function DataTable({ data }) { const [sortDescriptor, setSortDescriptor] = useState({ column: 'id', direction: 'ascending' });
const sortedData = useMemo(() => { const items = [...data]; items.sort((a, b) => { const first = a[sortDescriptor.column]; const second = b[sortDescriptor.column]; let cmp = first < second ? -1 : 1; return sortDescriptor.direction === 'descending' ? -cmp : cmp; }); return items; }, [data, sortDescriptor]);
return ( <div style={{ height: '600px', overflow: 'auto' }}> <Table> <Table.ScrollContainer> <Table.Content aria-label="Data table with sorting" sortDescriptor={sortDescriptor} onSortChange={setSortDescriptor} > <Table.Header> <Table.Column allowsSorting>ID</Table.Column> <Table.Column allowsSorting>Name</Table.Column> <Table.Column allowsSorting>Email</Table.Column> <Table.Column allowsSorting>Status</Table.Column> </Table.Header> <Table.Body> {sortedData.map((item) => ( <Table.Row key={item.id}> <Table.Cell>{item.id}</Table.Cell> <Table.Cell>{item.name}</Table.Cell> <Table.Cell>{item.email}</Table.Cell> <Table.Cell>{item.status}</Table.Cell> </Table.Row> ))} </Table.Body> </Table.Content> </Table.ScrollContainer> </Table> </div> );}Row Selection Pattern
Adding row selection required understanding HeroUI’s selection model:
function TableWithSelection({ data, onSelectionChange }) { const [selectedKeys, setSelectedKeys] = useState(new Set());
return ( <Table> <Table.ScrollContainer> <Table.Content aria-label="Table with selection" selectionMode="multiple" selectedKeys={selectedKeys} onSelectionChange={(keys) => { setSelectedKeys(keys); onSelectionChange(Array.from(keys)); }} > <Table.Header> <Table.Column> <Checkbox slot="selection" /> </Table.Column> <Table.Column>Name</Table.Column> <Table.Column>Email</Table.Column> </Table.Header> <Table.Body> {data.map((user) => ( <Table.Row key={user.id}> <Table.Cell> <Checkbox slot="selection" /> </Table.Cell> <Table.Cell>{user.name}</Table.Cell> <Table.Cell>{user.email}</Table.Cell> </Table.Row> ))} </Table.Body> </Table.Content> </Table.ScrollContainer> </Table> );}Custom Cell Rendering
Real tables need more than plain text. I created reusable cell components:
function StatusCell({ status }) { const statusColors = { active: 'bg-green-100 text-green-800', inactive: 'bg-gray-100 text-gray-800', pending: 'bg-yellow-100 text-yellow-800' };
return ( <span className={`px-2 py-1 rounded-full text-sm ${statusColors[status]}`}> {status} </span> );}
function ActionsCell({ item, onEdit, onDelete }) { return ( <div className="flex gap-2"> <button onClick={() => onEdit(item)} className="text-blue-600 hover:text-blue-800" > Edit </button> <button onClick={() => onDelete(item)} className="text-red-600 hover:text-red-800" > Delete </button> </div> );}TanStack Table Integration for Complex Requirements
When I needed advanced features like column grouping, filtering, and server-side pagination, HeroUI’s built-in sorting wasn’t enough. TanStack Table provides headless table logic that works well with HeroUI:
import { useReactTable, getCoreRowModel, getSortedRowModel } from '@tanstack/react-table';import { Table } from '@heroui/react';
function AdvancedDataTable({ data }) { const [sorting, setSorting] = useState([]);
const columns = [ { accessorKey: 'name', header: 'Name' }, { accessorKey: 'email', header: 'Email' }, { accessorKey: 'role', header: 'Role' } ];
const table = useReactTable({ data, columns, state: { sorting }, onSortingChange: setSorting, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel() });
return ( <Table> <Table.ScrollContainer> <Table.Content aria-label="TanStack table"> <Table.Header> {table.getHeaderGroups().map(headerGroup => ( headerGroup.headers.map(header => ( <Table.Column key={header.id} allowsSorting> {header.column.columnDef.header} </Table.Column> )) ))} </Table.Header> <Table.Body> {table.getRowModel().rows.map(row => ( <Table.Row key={row.id}> {row.getVisibleCells().map(cell => ( <Table.Cell key={cell.id}> {cell.getValue()} </Table.Cell> ))} </Table.Row> ))} </Table.Body> </Table.Content> </Table.ScrollContainer> </Table> );}Complete Implementation
Here’s the full component I use in production:
"use client";
import { useState, useMemo } from 'react';import { Table, Checkbox } from '@heroui/react';
const SAMPLE_DATA = Array.from({ length: 10000 }, (_, i) => ({ id: i + 1, name: `User ${i + 1}`, email: `user${i + 1}@example.com`, role: ['Admin', 'Editor', 'Viewer'][i % 3], status: ['active', 'inactive', 'pending'][i % 3]}));
export function DataTable() { const [sortDescriptor, setSortDescriptor] = useState({ column: 'id', direction: 'ascending' }); const [selectedKeys, setSelectedKeys] = useState(new Set());
const sortedData = useMemo(() => { const items = [...SAMPLE_DATA]; items.sort((a, b) => { const first = a[sortDescriptor.column]; const second = b[sortDescriptor.column]; let cmp = first < second ? -1 : 1; return sortDescriptor.direction === 'descending' ? -cmp : cmp; }); return items; }, [sortDescriptor]);
return ( <div className="h-[600px] overflow-auto"> <Table> <Table.ScrollContainer> <Table.Content aria-label="Users data table" sortDescriptor={sortDescriptor} onSortChange={setSortDescriptor} selectionMode="multiple" selectedKeys={selectedKeys} onSelectionChange={setSelectedKeys} > <Table.Header> <Table.Column> <Checkbox slot="selection" /> </Table.Column> <Table.Column allowsSorting>ID</Table.Column> <Table.Column allowsSorting>Name</Table.Column> <Table.Column allowsSorting>Email</Table.Column> <Table.Column allowsSorting>Role</Table.Column> <Table.Column allowsSorting>Status</Table.Column> </Table.Header> <Table.Body> {sortedData.map((user) => ( <Table.Row key={user.id}> <Table.Cell> <Checkbox slot="selection" /> </Table.Cell> <Table.Cell>{user.id}</Table.Cell> <Table.Cell>{user.name}</Table.Cell> <Table.Cell>{user.email}</Table.Cell> <Table.Cell>{user.role}</Table.Cell> <Table.Cell> <span className={`px-2 py-1 rounded-full text-sm ${ user.status === 'active' ? 'bg-green-100 text-green-800' : user.status === 'inactive' ? 'bg-gray-100 text-gray-800' : 'bg-yellow-100 text-yellow-800' }`}> {user.status} </span> </Table.Cell> </Table.Row> ))} </Table.Body> </Table.Content> </Table.ScrollContainer> <Table.Footer> <div className="flex justify-between items-center p-4"> <span>{selectedKeys.size} selected</span> <span>{sortedData.length} total rows</span> </div> </Table.Footer> </Table> </div> );}Why HeroUI’s Approach Works
A Reddit commenter captured HeroUI’s philosophy well:
“Hero’s table offers the perfect amount of functionality while not being opinionated about filtering/sorting.” - Dan6erbond2
This non-opinionated approach means:
- You control the sorting logic (server-side or client-side)
- Filtering is your choice (search input, dropdowns, etc.)
- HeroUI handles the UI, accessibility, and virtualization
Key Takeaways
- Start simple - Basic table setup is minimal code
- Add sorting with
allowsSorting- Column-level prop plus state management - Virtualize for 1000+ rows - Essential for performance with large datasets
- Set explicit container height - Virtualization won’t work without it
- Memoize sorted data - Prevent unnecessary re-sorts on every render
- Use stable keys - Array indices cause re-render issues when sorting
- Consider TanStack Table - For complex requirements like grouping or server-side operations
HeroUI v3 strikes a good balance between providing useful built-in features and staying flexible enough for custom implementations. The sorting API is straightforward, and the virtualization integration handles the performance concerns that make large datasets problematic in React.
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments