Skip to content

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:

  1. Calculate layout for all 5,000 rows
  2. Keep all elements in memory
  3. Re-render everything on any state change
5,000 rows × 6 cells × (element + text node + event listeners)
= ~60,000+ DOM nodes
= Frozen browser

The 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.ScrollContainer provides scrollable wrapper
  • Table.Content requires aria-label for accessibility
  • Always use unique key on each Table.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

PropComponentTypeDescription
allowsSortingTable.ColumnbooleanEnables sorting UI for column
sortDescriptorTable.ContentSortDescriptorCurrent sort state
onSortChangeTable.Content(descriptor) => voidSort 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:

  1. Forgot to set container height - Without explicit height, all items render to DOM, negating virtualization
  2. Skipped estimatedRowHeight - Scrollbar was inaccurate, causing jumpy behavior
  3. 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:

components/DataTable.jsx
"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

  1. Start simple - Basic table setup is minimal code
  2. Add sorting with allowsSorting - Column-level prop plus state management
  3. Virtualize for 1000+ rows - Essential for performance with large datasets
  4. Set explicit container height - Virtualization won’t work without it
  5. Memoize sorted data - Prevent unnecessary re-sorts on every render
  6. Use stable keys - Array indices cause re-render issues when sorting
  7. 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