Basic Table
This guide walks you through building a complete paginated table with @requence/table. By the end you’ll have virtual scrolling, page-based data fetching, column resizing with persistence, skeleton loading, and an empty state.
-
Define the data type
Section titled “Define the data type”Start with a TypeScript interface for your row data:
interface User {id: stringname: stringemail: stringrole: 'admin' | 'editor' | 'viewer'} -
Set up the API fetch function
Section titled “Set up the API fetch function”Create the function that
useTableCachewill call to fetch pages. It receives anoffsetandlimitand must return{ items, total }:async function fetchUsers(offset: number, limit: number) {const res = await fetch(`/api/users?offset=${offset}&limit=${limit}`)const data = await res.json()return { items: data.users as User[], total: data.total as number }} -
Create the cache
Section titled “Create the cache”Use
useTableCacheto set up paginated, Suspense-compatible data fetching. Thecomparefunction determines sort order for real-time upserts:import { useTableCache } from '@requence/table'function UserTable() {const cache = useTableCache<User>('users', {pageSize: 50,getItemId: (user) => user.id,compare: (a, b) => a.name.localeCompare(b.name),fetchItems: fetchUsers,})// cache.totalCount — total rows// cache.getItem(index) — get row by index (undefined if not fetched)// cache.handleRangeChange — pass to VirtualTable.onRangeChange} -
Render the table
Section titled “Render the table”Compose the
VirtualTablewithHeader,Column,Body,Row, andCell:import { VirtualTable } from '@requence/table'return (<VirtualTabletotalCount={cache.totalCount}rowHeight={40}onRangeChange={cache.handleRangeChange}className="h-[600px]"aria-label="Users"><VirtualTable.Header className="bg-zinc-900 text-xs uppercase text-zinc-500"><VirtualTable.Column width="2fr" className="px-4 py-2">Name</VirtualTable.Column><VirtualTable.Column width="2fr" className="px-4 py-2">Email</VirtualTable.Column><VirtualTable.Column width="1fr" className="px-4 py-2">Role</VirtualTable.Column></VirtualTable.Header><VirtualTable.Body>{(index) => {const user = cache.getItem(index)if (!user) return nullreturn (<VirtualTable.Row className="border-b border-zinc-800"><VirtualTable.Cell className="px-4">{user.name}</VirtualTable.Cell><VirtualTable.Cell className="px-4">{user.email}</VirtualTable.Cell><VirtualTable.Cell className="px-4">{user.role}</VirtualTable.Cell></VirtualTable.Row>)}}</VirtualTable.Body></VirtualTable>)When
cache.getItem(index)returnsundefined(page not yet fetched), the render function returnsnull. Right now nothing renders for those rows — we’ll add skeleton loading in step 6. -
Add column resizing
Section titled “Add column resizing”Use
useTableColumnWidthsto make columns resizable and persist widths tolocalStorage:import { useTableColumnWidths } from '@requence/table'const { register, reset } = useTableColumnWidths({persist: 'user-table',})Replace static width/className props on each column with
register():<VirtualTable.Column{...register('name', { defaultValue: '2fr', relative: true })}className="px-4 py-2">Name</VirtualTable.Column>The
register()spread provideswidth,resizable, andonResizeEnd. YourclassNameis merged on top. -
Add skeleton loading
Section titled “Add skeleton loading”Add a
SkeletonRowto show placeholders while pages are loading:<VirtualTable.SkeletonRow className="border-b border-zinc-800"><VirtualTable.Cell className="px-4"><div className="h-3 w-24 animate-pulse rounded bg-zinc-800" /></VirtualTable.Cell><VirtualTable.Cell className="px-4"><div className="h-3 w-40 animate-pulse rounded bg-zinc-800" /></VirtualTable.Cell><VirtualTable.Cell className="px-4"><div className="h-3 w-16 animate-pulse rounded bg-zinc-800" /></VirtualTable.Cell></VirtualTable.SkeletonRow>Whenever the
Bodyrender function returnsnull,VirtualTablerenders this skeleton in the row’s place. The skeleton shares the same column layout, so the placeholders align with the header columns. -
Add empty state and footer
Section titled “Add empty state and footer”Add an
Emptysub-component for when there are no results, and aFooterto show the current scroll position:<VirtualTable.Empty className="py-20 text-center text-zinc-500">No users found.</VirtualTable.Empty><VirtualTable.Footer className="border-t border-zinc-800 px-4 py-2 text-xs text-zinc-500">{({ start, end }) => (<span>Showing rows {start + 1}–{end} of {cache.totalCount}</span>)}</VirtualTable.Footer>The empty state renders when
totalCountis0. The footer receives the current visible{ start, end }range and only renders whentotalCount > 0.
Full example
Section titled “Full example”Here’s the complete component combining all the steps above:
import { Suspense } from 'react'import { VirtualTable, useTableCache, useTableColumnWidths } from '@requence/table'
interface User { id: string name: string email: string role: 'admin' | 'editor' | 'viewer'}
async function fetchUsers(offset: number, limit: number) { const res = await fetch(`/api/users?offset=${offset}&limit=${limit}`) const data = await res.json() return { items: data.users as User[], total: data.total as number }}
function UserTable() { const { register, reset } = useTableColumnWidths({ persist: 'user-table', })
const cache = useTableCache<User>('users', { pageSize: 50, getItemId: (user) => user.id, compare: (a, b) => a.name.localeCompare(b.name), fetchItems: fetchUsers, })
return ( <div> <div className="flex items-center justify-between px-4 py-2"> <h2 className="text-sm font-medium">Users</h2> <button onClick={reset} className="text-xs text-zinc-400 hover:text-zinc-200"> Reset widths </button> </div>
<VirtualTable totalCount={cache.totalCount} rowHeight={40} onRangeChange={cache.handleRangeChange} className="h-[600px]" aria-label="Users" > <VirtualTable.Header className="bg-zinc-900 text-xs uppercase text-zinc-500"> <VirtualTable.Column {...register('name', { defaultValue: '2fr', relative: true })} className="px-4 py-2" > Name </VirtualTable.Column> <VirtualTable.Column {...register('email', { defaultValue: '2fr', relative: true })} className="px-4 py-2" > Email </VirtualTable.Column> <VirtualTable.Column {...register('role', { defaultValue: '1fr', relative: true })} className="px-4 py-2" > Role </VirtualTable.Column> </VirtualTable.Header>
<VirtualTable.Body> {(index) => { const user = cache.getItem(index) if (!user) return null return ( <VirtualTable.Row className="border-b border-zinc-800 hover:bg-zinc-900/50"> <VirtualTable.Cell className="px-4">{user.name}</VirtualTable.Cell> <VirtualTable.Cell className="px-4">{user.email}</VirtualTable.Cell> <VirtualTable.Cell className="px-4 capitalize">{user.role}</VirtualTable.Cell> </VirtualTable.Row> ) }} </VirtualTable.Body>
<VirtualTable.SkeletonRow className="border-b border-zinc-800"> <VirtualTable.Cell className="px-4"> <div className="h-3 w-24 animate-pulse rounded bg-zinc-800" /> </VirtualTable.Cell> <VirtualTable.Cell className="px-4"> <div className="h-3 w-40 animate-pulse rounded bg-zinc-800" /> </VirtualTable.Cell> <VirtualTable.Cell className="px-4"> <div className="h-3 w-16 animate-pulse rounded bg-zinc-800" /> </VirtualTable.Cell> </VirtualTable.SkeletonRow>
<VirtualTable.Empty className="py-20 text-center text-zinc-500"> No users found. </VirtualTable.Empty>
<VirtualTable.Footer className="border-t border-zinc-800 px-4 py-2 text-xs text-zinc-500"> {({ start, end }) => ( <span> Showing rows {start + 1}–{end} of {cache.totalCount} </span> )} </VirtualTable.Footer> </VirtualTable> </div> )}
export default function UsersPage() { return ( <Suspense fallback={<div className="p-8 text-zinc-500">Loading users…</div>}> <UserTable /> </Suspense> )}