@pyreon/table is the Pyreon adapter for TanStack Table. It wraps TanStack Table's core with a reactive useTable hook that returns a signal-based table instance, and provides flexRender for rendering column definitions in Pyreon templates.
Installation
npm install @pyreon/tablebun add @pyreon/tablepnpm add @pyreon/tableyarn add @pyreon/tableTanStack Table core is included as a dependency -- all exports from @tanstack/table-core are re-exported for convenience.
Basic Usage
Use useTable to create a reactive table instance. Options are passed as a function so reactive signals (e.g., data, columns, sorting state) can be read inside and the table updates automatically.
import { defineComponent, For } from '@pyreon/core'
import { signal } from '@pyreon/reactivity'
import { useTable, flexRender, flexRenderCell, getCoreRowModel, createColumnHelper } from '@pyreon/table'
interface Person {
name: string
age: number
email: string
}
const columnHelper = createColumnHelper<Person>()
const columns = [
columnHelper.accessor('name', { header: 'Name' }),
columnHelper.accessor('age', { header: 'Age' }),
columnHelper.accessor('email', { header: 'Email' }),
]
const PeopleTable = defineComponent(() => {
const data = signal<Person[]>([
{ name: 'Alice', age: 30, email: 'alice@example.com' },
{ name: 'Bob', age: 25, email: 'bob@example.com' },
])
const table = useTable(() => ({
data: data(),
columns,
getCoreRowModel: getCoreRowModel(),
}))
return () => (
<table>
<thead>
<For each={() => table().getHeaderGroups()} by={(g) => g.id}>
{(headerGroup) => (
<tr>
<For each={() => headerGroup.headers} by={(h) => h.id}>
{(header) => (
<th onClick={header.column.getToggleSortingHandler()}>
{flexRender(header.column.columnDef.header, header.getContext())}
</th>
)}
</For>
</tr>
)}
</For>
</thead>
<tbody>
<For each={() => table().getRowModel().rows} by={(r) => r.id}>
{(row) => (
<tr>
<For each={() => row.getVisibleCells()} by={(c) => c.id}>
{/* Fine-grained: pass the `table` accessor (not `table()`) so a
single-cell edit patches ONLY this cell — no re-render of
the row or table, no memoization boilerplate. */}
{(cell) => <td>{() => flexRenderCell(table, row.id, cell.column.id)}</td>}
</For>
</tr>
)}
</For>
</tbody>
</table>
)
})Use <For> (not .map()) for the rows and cells: it keeps Pyreon's keyed reconciliation (DOM nodes are reused/moved, not rebuilt), and flexRenderCellinside an accessor gives fine-grained per-cell updates. A plain .map() rebuilds the whole <tbody> on every change — the worst-case DOM churn.
useTable
function useTable<TData extends RowData>(options: () => TableOptions<TData>): Computed<Table<TData>>Creates a reactive TanStack Table instance. Returns a Computed<Table<TData>> -- a read-only signal that holds the table instance. Read it in effects or templates to track state changes.
How It Works
Internally, useTable:
Creates an internal
signal<TableState>to hold the adapter-managed state.Creates the TanStack Table instance via
createTable()with resolved options.Sets up a reactive
effect()that re-syncs options whenever signals read inside the options function change.Uses a version counter signal to force the returned
Computedto re-notify consumers when table state changes (since the table object identity does not change).Maintains per-row version signals so
flexRenderCellcells subscribe to only their own row — an in-place data edit re-runs just the changed rows' cells (see Fine-grained cell updates).Registers an
onUnmountcallback to dispose the effect when the component unmounts.
Reactive Options
Because options are passed as a function, you can use signals for dynamic data. When any signal read inside the options function changes, the table options are updated and the table re-evaluates.
const data = signal<Person[]>([])
const columns = signal<ColumnDef<Person, unknown>[]>([
{ accessorKey: 'name', header: 'Name' },
{ accessorKey: 'age', header: 'Age' },
])
const table = useTable(() => ({
data: data(),
columns: columns(),
getCoreRowModel: getCoreRowModel(),
}))
// Table updates automatically when data or columns change:
data.set([
{ name: 'Alice', age: 30 },
{ name: 'Bob', age: 25 },
])
// table() now returns 2 rows
columns.set([{ accessorKey: 'name', header: 'Name' }])
// table() now has 1 columnReactive Derived State
Use computed() to derive values from the table signal. These derived computeds automatically update when table state changes:
import { computed } from '@pyreon/reactivity'
const data = signal<Person[]>(defaultData)
const table = useTable(() => ({
data: data(),
columns,
getCoreRowModel: getCoreRowModel(),
}))
const rowCount = computed(() => table().getRowModel().rows.length)
rowCount() // 3
data.set([...defaultData, { name: 'Diana', age: 28 }])
rowCount() // 4
data.set([defaultData[0]])
rowCount() // 1Fine-grained cell updates
Render live cells with flexRenderCell(table, row.id, cell.column.id) inside an accessor. This is the fine-grained per-cell primitive: on an in-place data edit it patches only the changed rows' cells — no row re-render, no table re-render, and no memoization boilerplate.
<For each={() => table().getRowModel().rows} by={(r) => r.id}>
{(row) => (
<tr>
<For each={() => row.getVisibleCells()} by={(c) => c.id}>
{(cell) => <td>{() => flexRenderCell(table, row.id, cell.column.id)}</td>}
</For>
</tr>
)}
</For>Two rules make it fine-grained:
Pass the
tableaccessor, nottable(). With the accessor, the cell subscribes to only its own row's signal (the adapter tracks which rows'originaldata changed). Passing the resolved instancetable()still works but subscribes to the whole table — every cell re-runs on any change.Wrap it in an explicit accessor
{() => …}. Inside a keyed<For>, therow/cellobjects are captured once (the reconciler reuses the DOM node and never re-runs the cell body). PlainflexRender(cell.column.columnDef.cell, cell.getContext())therefore freezeswhen a cell value changes in place.flexRenderCellre-navigates to the live cell each read.
A table-state change (sort, filter, selection, column visibility) re-runs all cells (coarse — correct by default for cells that render state, e.g. a selection checkbox). An in-place data edit is the fine-grained path.
:::caution Reorder-on-data-edit limitation A data edit that changes the sort order (editing the very column you're sorted by) updates every cell to the correct value but does not re-position the keyed rows until the next structure/state change. This is a pre-existing base-adapter limitation of the sorted-row-model + <For> interaction (it affects plain flexRender cells too — not just flexRenderCell). Re-ordering via the sort controls (toggleSorting / setSorting) works normally. Workaround: re-apply sorting after such an edit, or sort by a column you don't edit in place. :::
Binding per-cell values that change (column width, sort indicators)
When a table value changes on a state-only update — a column resize (getSize()), a sort direction indicator, a visibility flag — bind it reactively at the point of use, not as a static prop.
table() does re-notify on these changes (it's a live signal-backed accessor). But a keyed <For> reuses each cell by key and does not re-run the cell's body on a state change — that's how keyed reconciliation stays fast. So a value captured once at first mount (a static prop or a static style) freezes:
// ❌ Frozen on resize — width is captured once when the cell first mounts,
// and the keyed <For> never re-runs this cell body.
<For each={() => table().getHeaderGroups()[0].headers} by={(h) => h.id}>
{(header) => <th style={{ width: header.getSize() + 'px' }}>…</th>}
</For>// ✅ Tracks the resize — the width is read inside a reactive style closure,
// so it re-subscribes and updates when column sizing changes.
<For each={() => table().getHeaderGroups()[0].headers} by={(h) => h.id}>
{(header) => (
<th style={() => ({ width: `${table().getColumn(header.column.id)!.getSize()}px` })}>
…
</th>
)}
</For>This is fundamental fine-grained reactivity (the same as Solid's <For>): read reactive values inside a reactive scope at the point they're used. A value passed as a static prop through a keyed list is captured once. The same applies to any per-cell value that changes without the row identity changing.
State Change Callbacks
The adapter automatically manages internal state via onStateChange. When you provide your own state and change handlers (e.g., onSortingChange, onPaginationChange), they are called in addition to the adapter's internal state management. The adapter merges your provided state with its internal state.
const sorting = signal<SortingState>([])
const table = useTable(() => ({
data: data(),
columns,
state: { sorting: sorting() },
onSortingChange: (updater) => {
sorting.update((prev) => (typeof updater === 'function' ? updater(prev) : updater))
},
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
}))Handling Updaters
TanStack Table state change callbacks receive an Updater<T> which can be either a function or a direct value. Always handle both cases:
onSortingChange: (updater) => {
sorting.update((prev) => (typeof updater === 'function' ? updater(prev) : updater))
}This pattern applies to all on*Change callbacks: onSortingChange, onPaginationChange, onColumnFiltersChange, onRowSelectionChange, onColumnVisibilityChange, onExpandedChange, onGroupingChange, etc.
Cleanup
useTable registers an onUnmount callback to dispose its internal effect when the component unmounts. No manual cleanup is needed.
flexRender
function flexRender<TData extends RowData, TValue>(
component: ((props: TValue) => unknown) | string | number | null | undefined,
props: TValue,
): unknownRenders a TanStack Table column definition template (header, cell, or footer). Handles:
Strings and numbers -- returned as-is (e.g.,
"Name"or42)Functions -- called with the provided props (render functions or components)
VNodes -- passed through to the renderer (detected by checking for
type,props, andchildrenproperties)null/undefined -- returns
nullOther types (booleans, plain objects) -- returns
null
// Header
flexRender(header.column.columnDef.header, header.getContext())
// Cell
flexRender(cell.column.columnDef.cell, cell.getContext())
// Footer
flexRender(footer.column.columnDef.footer, footer.getContext())Custom Cell Renderers
Use function column definitions to render custom Pyreon components:
const columns = [
columnHelper.accessor('name', {
header: 'Name',
cell: (info) => <strong>{info.getValue()}</strong>,
}),
columnHelper.accessor('status', {
header: 'Status',
cell: (info) => <StatusBadge status={info.getValue()} />,
}),
columnHelper.accessor('avatar', {
header: 'Avatar',
cell: (info) => (
<img
src={info.getValue()}
alt={info.row.original.name}
width={32}
height={32}
style={{ borderRadius: '50%' }}
/>
),
}),
columnHelper.accessor('actions', {
header: () => null,
cell: (info) => (
<div style={{ display: 'flex', gap: '4px' }}>
<button onClick={() => editRow(info.row.original)}>Edit</button>
<button onClick={() => deleteRow(info.row.original.id)}>Delete</button>
</div>
),
}),
]Custom Header Renderers
Headers can also be functions for interactive headers:
columnHelper.accessor('name', {
header: ({ column }) => (
<button onClick={() => column.toggleSorting()}>
Name {column.getIsSorted() === 'asc' ? '(asc)' : column.getIsSorted() === 'desc' ? '(desc)' : ''}
</button>
),
cell: (info) => info.getValue(),
})flexRenderCell
function flexRenderCell<TData extends RowData>(
table: Table<TData> | Computed<Table<TData>>,
rowId: string,
columnId: string,
): unknownThe fine-grained per-cell renderer. Where flexRender renders a cell from a capturedcell object (which freezes inside a keyed <For> when the value changes), flexRenderCellre-navigates to the live cell from the current row model on every read. Place it inside an accessor and pass the table accessor for fine-grained updates:
<For each={() => row.getVisibleCells()} by={(c) => c.id}>
{(cell) => <td>{() => flexRenderCell(table, row.id, cell.column.id)}</td>}
</For>Pass the accessor
table(nottable()): the cell then subscribes to only its own row's signal, so an in-place data edit re-runs just the changed rows' cells — matching a hand-memoized@tanstack/react-tablerow, with noReact.memoboilerplate.Passing the resolved instance
table()still renders correctly but subscribes coarsely (every cell re-runs on any change).Returns
nullwhen the row is not in the current (filtered / paginated) row model.
See Fine-grained cell updates for the full pattern and the reorder-on-data-edit caveat.
Column Definitions
TanStack Table offers several column types, all re-exported from @pyreon/table.
Using createColumnHelper
The type-safe way to define columns:
import { createColumnHelper } from '@pyreon/table'
interface Person {
name: string
age: number
email: string
department: { name: string; id: number }
}
const columnHelper = createColumnHelper<Person>()
const columns = [
// Simple accessor columns
columnHelper.accessor('name', {
header: 'Full Name',
cell: (info) => info.getValue(),
footer: () => 'Total',
}),
columnHelper.accessor('age', {
header: 'Age',
cell: (info) => info.getValue(),
}),
columnHelper.accessor('email', {
header: 'Email',
cell: (info) => <a href={`mailto:${info.getValue()}`}>{info.getValue()}</a>,
}),
// Accessor function for nested data
columnHelper.accessor((row) => row.department.name, {
id: 'departmentName',
header: 'Department',
cell: (info) => info.getValue(),
}),
// Display column (no accessor, custom rendering)
columnHelper.display({
id: 'actions',
header: 'Actions',
cell: (info) => (
<button onClick={() => handleEdit(info.row.original)}>
Edit
</button>
),
}),
]Using Plain Column Definitions
You can also define columns as plain objects:
import type { ColumnDef } from '@pyreon/table'
const columns: ColumnDef<Person, unknown>[] = [
{
accessorKey: 'name',
header: 'Name',
},
{
accessorKey: 'age',
header: 'Age',
},
{
id: 'fullInfo',
accessorFn: (row) => `${row.name} (${row.age})`,
header: 'Summary',
},
]Column Groups
Group related columns under a shared header:
const columns = [
columnHelper.group({
header: 'Personal Info',
columns: [
columnHelper.accessor('name', { header: 'Name' }),
columnHelper.accessor('age', { header: 'Age' }),
],
}),
columnHelper.group({
header: 'Contact',
columns: [
columnHelper.accessor('email', { header: 'Email' }),
columnHelper.accessor('phone', { header: 'Phone' }),
],
}),
]Sorting
Basic Sorting
Enable sorting by adding getSortedRowModel:
import { useTable, getCoreRowModel, getSortedRowModel } from '@pyreon/table'
const table = useTable(() => ({
data: data(),
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
}))With the adapter's built-in state management, sorting works automatically. Toggle sorting on a column:
// Toggle sorting on the "age" column
table().getColumn('age')!.toggleSorting(false) // ascending
table().getColumn('age')!.toggleSorting(true) // descending
// Check current sort state
table().getState().sorting
// [{ id: 'age', desc: false }]Controlled Sorting
For full control over sort state, manage it with a signal:
import type { SortingState } from '@pyreon/table'
const sorting = signal<SortingState>([])
const table = useTable(() => ({
data: data(),
columns,
state: { sorting: sorting() },
onSortingChange: (updater) => {
sorting.update((prev) => (typeof updater === 'function' ? updater(prev) : updater))
},
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
}))Multi-Column Sorting
Enable multi-column sorting so users can sort by multiple columns:
const table = useTable(() => ({
data: data(),
columns,
enableMultiSort: true,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
}))Sortable Header Component
function SortableHeader({ column, label }) {
const sorted = column.getIsSorted()
return (
<button
onClick={() => column.toggleSorting()}
style={{ cursor: 'pointer', fontWeight: 'bold' }}
>
{label}
{sorted === 'asc' ? ' ↑' : sorted === 'desc' ? ' ↓' : ''}
</button>
)
}
const columns = [
columnHelper.accessor('name', {
header: ({ column }) => <SortableHeader column={column} label="Name" />,
}),
columnHelper.accessor('age', {
header: ({ column }) => <SortableHeader column={column} label="Age" />,
sortingFn: 'basic', // numeric sorting
}),
]Custom Sort Functions
columnHelper.accessor('priority', {
header: 'Priority',
sortingFn: (rowA, rowB, columnId) => {
const order = { high: 3, medium: 2, low: 1 }
const a = order[rowA.getValue(columnId)] ?? 0
const b = order[rowB.getValue(columnId)] ?? 0
return a - b
},
})Filtering
Column Filters
Filter individual columns with getFilteredRowModel:
import { useTable, getCoreRowModel, getFilteredRowModel } from '@pyreon/table'
import type { ColumnFiltersState } from '@pyreon/table'
const columnFilters = signal<ColumnFiltersState>([])
const table = useTable(() => ({
data: data(),
columns,
state: { columnFilters: columnFilters() },
onColumnFiltersChange: (updater) => {
columnFilters.update((prev) => (typeof updater === 'function' ? updater(prev) : updater))
},
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
}))Set a filter value on a column:
table().getColumn('name')!.setFilterValue('Ali')
// Only rows where "name" includes "Ali" are shownAutomatic Filtering
Without controlled state, the adapter manages filter state internally:
const table = useTable(() => ({
data: data(),
columns,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
}))
// Set a filter directly on the column
table().getColumn('name')!.setFilterValue('Ali')
const filtered = table().getRowModel().rows
// filtered has 1 row: AliceGlobal Filter
Apply a single search query across all columns:
const globalFilter = signal('')
const table = useTable(() => ({
data: data(),
columns,
state: { globalFilter: globalFilter() },
onGlobalFilterChange: (updater) => {
globalFilter.update((prev) =>
typeof updater === 'function' ? updater(prev) : updater
)
},
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
}))
// Search input
<input
type="text"
placeholder="Search all columns..."
value={globalFilter()}
onInput={(e) => globalFilter.set(e.target.value)}
/>Custom Filter Functions
columnHelper.accessor('age', {
header: 'Age',
filterFn: (row, columnId, filterValue) => {
const age = row.getValue<number>(columnId)
const [min, max] = filterValue as [number, number]
return age >= min && age <= max
},
})
// Usage: filter ages between 20 and 35
table().getColumn('age')!.setFilterValue([20, 35])Filter Input Component
function ColumnFilter({ column }) {
return (
<input
type="text"
value={(column.getFilterValue() ?? '') as string}
onInput={(e) => column.setFilterValue(e.target.value)}
placeholder={`Filter ${column.id}...`}
style={{ width: '100%', padding: '4px' }}
/>
)
}
// In the header:
{
table()
.getHeaderGroups()
.map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th key={header.id}>
{flexRender(header.column.columnDef.header, header.getContext())}
{header.column.getCanFilter() && <ColumnFilter column={header.column} />}
</th>
))}
</tr>
))
}Pagination
Client-Side Pagination
import { useTable, getCoreRowModel, getPaginationRowModel } from '@pyreon/table'
import type { PaginationState } from '@pyreon/table'
const pagination = signal<PaginationState>({ pageIndex: 0, pageSize: 10 })
const table = useTable(() => ({
data: data(),
columns,
state: { pagination: pagination() },
onPaginationChange: (updater) => {
pagination.update((prev) => (typeof updater === 'function' ? updater(prev) : updater))
},
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
}))Automatic Pagination
Without controlled state, pagination is managed internally with a default page size of 10:
const bigData = Array.from({ length: 25 }, (_, i) => ({
name: `Person ${i}`,
age: 20 + i,
}))
const table = useTable(() => ({
data: bigData,
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
}))
table().getRowModel().rows.length // 10 (first page)
table().getCanNextPage() // true
table().getCanPreviousPage() // false
table().nextPage()
table().getRowModel().rows.length // 10 (second page)
table().getRowModel().rows[0].original.name // "Person 10"
table().nextPage()
table().getRowModel().rows.length // 5 (last page, only 5 remaining)
table().getCanNextPage() // falsePagination Controls
function PaginationControls({ table }) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', padding: '8px 0' }}>
<button onClick={() => table().firstPage()} disabled={!table().getCanPreviousPage()}>
{'<<'}
</button>
<button onClick={() => table().previousPage()} disabled={!table().getCanPreviousPage()}>
{'<'}
</button>
<span>
Page {table().getState().pagination.pageIndex + 1} of {table().getPageCount()}
</span>
<button onClick={() => table().nextPage()} disabled={!table().getCanNextPage()}>
{'>'}
</button>
<button onClick={() => table().lastPage()} disabled={!table().getCanNextPage()}>
{'>>'}
</button>
<select
value={table().getState().pagination.pageSize}
onChange={(e) => table().setPageSize(Number(e.target.value))}
>
{[10, 20, 50, 100].map((size) => (
<option key={size} value={size}>
Show {size}
</option>
))}
</select>
</div>
)
}Page Size Selector
// Change page size programmatically
table().setPageSize(25)
// Go to a specific page
table().setPageIndex(2) // third page (zero-indexed)Server-Side Pagination
For server-side pagination, manage the data fetching externally and disable client-side pagination:
import { signal } from '@pyreon/reactivity'
const data = signal<Person[]>([])
const totalRows = signal(0)
const pagination = signal<PaginationState>({ pageIndex: 0, pageSize: 20 })
async function fetchPage(pageIndex: number, pageSize: number) {
const response = await fetch(`/api/people?page=${pageIndex}&size=${pageSize}`)
const result = await response.json()
data.set(result.items)
totalRows.set(result.total)
}
// Initial fetch
fetchPage(0, 20)
const table = useTable(() => ({
data: data(),
columns,
pageCount: Math.ceil(totalRows() / pagination().pageSize),
state: { pagination: pagination() },
onPaginationChange: (updater) => {
const newPagination = typeof updater === 'function' ? updater(pagination.peek()) : updater
pagination.set(newPagination)
fetchPage(newPagination.pageIndex, newPagination.pageSize)
},
manualPagination: true,
getCoreRowModel: getCoreRowModel(),
}))Row Selection
Enabling Row Selection
const rowSelection = signal<Record<string, boolean>>({})
const table = useTable(() => ({
data: data(),
columns,
state: { rowSelection: rowSelection() },
onRowSelectionChange: (updater) => {
rowSelection.update((prev) => (typeof updater === 'function' ? updater(prev) : updater))
},
enableRowSelection: true,
getCoreRowModel: getCoreRowModel(),
}))Automatic Row Selection
Without controlled state, selection works out of the box:
const table = useTable(() => ({
data: data(),
columns,
getCoreRowModel: getCoreRowModel(),
enableRowSelection: true,
}))
table().getSelectedRowModel().rows // []
table().getRowModel().rows[0].toggleSelected(true)
table().getSelectedRowModel().rows // [first row]
table().getRowModel().rows[0].toggleSelected(false)
table().getSelectedRowModel().rows // []Selection Checkbox Column
const columns = [
columnHelper.display({
id: 'select',
header: ({ table }) => (
<input
type="checkbox"
checked={table.getIsAllRowsSelected()}
indeterminate={table.getIsSomeRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
checked={row.getIsSelected()}
disabled={!row.getCanSelect()}
onChange={row.getToggleSelectedHandler()}
/>
),
}),
// ... other columns
]Getting Selected Rows
// Get selected row models
const selectedRows = table().getSelectedRowModel().rows
// Get selected row data
const selectedData = selectedRows.map((row) => row.original)
// Check how many are selected
const selectedCount = Object.keys(table().getState().rowSelection).lengthConditional Row Selection
const table = useTable(() => ({
data: data(),
columns,
enableRowSelection: (row) => row.original.status !== 'locked',
getCoreRowModel: getCoreRowModel(),
}))Column Visibility
Toggle columns on and off:
const columnVisibility = signal<Record<string, boolean>>({})
const table = useTable(() => ({
data: data(),
columns,
state: { columnVisibility: columnVisibility() },
onColumnVisibilityChange: (updater) => {
columnVisibility.update((prev) => (typeof updater === 'function' ? updater(prev) : updater))
},
getCoreRowModel: getCoreRowModel(),
}))Automatic Column Visibility
const table = useTable(() => ({
data: data(),
columns,
getCoreRowModel: getCoreRowModel(),
}))
table().getVisibleFlatColumns().length // 2
table().getColumn('age')!.toggleVisibility(false)
table().getVisibleFlatColumns().length // 1
table().getVisibleFlatColumns()[0].id // "name"
table().getColumn('age')!.toggleVisibility(true)
table().getVisibleFlatColumns().length // 2Column Visibility Toggle UI
function ColumnToggle({ table }) {
return (
<div style={{ padding: '8px' }}>
<label>
<input
type="checkbox"
checked={table().getIsAllColumnsVisible()}
onChange={table().getToggleAllColumnsVisibilityHandler()}
/>
Toggle All
</label>
{table()
.getAllLeafColumns()
.map((column) => (
<label key={column.id} style={{ display: 'block' }}>
<input
type="checkbox"
checked={column.getIsVisible()}
onChange={column.getToggleVisibilityHandler()}
/>
{column.id}
</label>
))}
</div>
)
}Column Ordering
Reorder columns programmatically:
import type { ColumnOrderState } from '@pyreon/table'
const columnOrder = signal<ColumnOrderState>([])
const table = useTable(() => ({
data: data(),
columns,
state: { columnOrder: columnOrder() },
onColumnOrderChange: (updater) => {
columnOrder.update((prev) => (typeof updater === 'function' ? updater(prev) : updater))
},
getCoreRowModel: getCoreRowModel(),
}))
// Reorder columns
columnOrder.set(['email', 'name', 'age'])Expanding and Grouping Rows
Row Expanding
For hierarchical data with sub-rows:
import { useTable, getCoreRowModel, getExpandedRowModel } from '@pyreon/table'
import type { ExpandedState } from '@pyreon/table'
const expanded = signal<ExpandedState>({})
const table = useTable(() => ({
data: treeData(),
columns,
state: { expanded: expanded() },
onExpandedChange: (updater) => {
expanded.update((prev) => (typeof updater === 'function' ? updater(prev) : updater))
},
getSubRows: (row) => row.children,
getCoreRowModel: getCoreRowModel(),
getExpandedRowModel: getExpandedRowModel(),
}))Expand Toggle in a Column
columnHelper.display({
id: 'expander',
header: () => null,
cell: ({ row }) => {
if (!row.getCanExpand()) return null
return (
<button onClick={row.getToggleExpandedHandler()}>
{row.getIsExpanded() ? '▼' : '▶'}
</button>
)
},
})Row Grouping
Group rows by column values:
import { useTable, getCoreRowModel, getGroupedRowModel, getExpandedRowModel } from '@pyreon/table'
import type { GroupingState } from '@pyreon/table'
const grouping = signal<GroupingState>([])
const table = useTable(() => ({
data: data(),
columns,
state: { grouping: grouping() },
onGroupingChange: (updater) => {
grouping.update((prev) => (typeof updater === 'function' ? updater(prev) : updater))
},
getCoreRowModel: getCoreRowModel(),
getGroupedRowModel: getGroupedRowModel(),
getExpandedRowModel: getExpandedRowModel(),
}))
// Group by department
grouping.set(['department'])Combining Features
Sorting + Filtering + Pagination
import { signal } from '@pyreon/reactivity'
import {
useTable,
getCoreRowModel,
getSortedRowModel,
getFilteredRowModel,
getPaginationRowModel,
createColumnHelper,
} from '@pyreon/table'
import type { SortingState, ColumnFiltersState, PaginationState } from '@pyreon/table'
interface Product {
id: number
name: string
category: string
price: number
stock: number
}
const columnHelper = createColumnHelper<Product>()
const columns = [
columnHelper.accessor('name', { header: 'Product' }),
columnHelper.accessor('category', { header: 'Category' }),
columnHelper.accessor('price', {
header: 'Price',
cell: (info) => `$${info.getValue().toFixed(2)}`,
}),
columnHelper.accessor('stock', {
header: 'Stock',
cell: (info) => {
const stock = info.getValue()
return (
<span style={{ color: stock < 10 ? 'red' : stock < 50 ? 'orange' : 'green' }}>{stock}</span>
)
},
}),
]
const ProductTable = defineComponent(() => {
const data = signal<Product[]>([
/* ... */
])
const sorting = signal<SortingState>([])
const columnFilters = signal<ColumnFiltersState>([])
const pagination = signal<PaginationState>({ pageIndex: 0, pageSize: 20 })
const table = useTable(() => ({
data: data(),
columns,
state: {
sorting: sorting(),
columnFilters: columnFilters(),
pagination: pagination(),
},
onSortingChange: (updater) => {
sorting.update((prev) => (typeof updater === 'function' ? updater(prev) : updater))
},
onColumnFiltersChange: (updater) => {
columnFilters.update((prev) => (typeof updater === 'function' ? updater(prev) : updater))
},
onPaginationChange: (updater) => {
pagination.update((prev) => (typeof updater === 'function' ? updater(prev) : updater))
},
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
}))
return () => (
<div>
<table>
<thead>
{table()
.getHeaderGroups()
.map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th key={header.id}>
{header.isPlaceholder ? null : (
<div>
<button onClick={header.column.getToggleSortingHandler()}>
{flexRender(header.column.columnDef.header, header.getContext())}
{header.column.getIsSorted() === 'asc' ? ' ↑' : ''}
{header.column.getIsSorted() === 'desc' ? ' ↓' : ''}
</button>
{header.column.getCanFilter() && (
<input
type="text"
value={(header.column.getFilterValue() ?? '') as string}
onInput={(e) => header.column.setFilterValue(e.target.value)}
placeholder="Filter..."
/>
)}
</div>
)}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table()
.getRowModel()
.rows.map((row) => (
<tr key={row.id}>
{row.getVisibleCells().map((cell) => (
<td key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</td>
))}
</tr>
))}
</tbody>
</table>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', padding: '8px 0' }}>
<button onClick={() => table().previousPage()} disabled={!table().getCanPreviousPage()}>
Previous
</button>
<span>
Page {table().getState().pagination.pageIndex + 1} of {table().getPageCount()}
</span>
<button onClick={() => table().nextPage()} disabled={!table().getCanNextPage()}>
Next
</button>
</div>
</div>
)
})Server-Side Data Loading with @pyreon/query
Combine useTable with @pyreon/query for server-driven tables:
import { signal } from '@pyreon/reactivity'
import { useQuery } from '@pyreon/query'
import { useTable, getCoreRowModel, createColumnHelper } from '@pyreon/table'
import type { SortingState, PaginationState } from '@pyreon/table'
interface ApiResponse {
items: Person[]
total: number
}
const sorting = signal<SortingState>([])
const pagination = signal<PaginationState>({ pageIndex: 0, pageSize: 20 })
// Build query key from table state
const queryKey = () => ['people', pagination().pageIndex, pagination().pageSize, sorting()]
const { data, isLoading, error } = useQuery<ApiResponse>({
queryKey: queryKey(),
queryFn: async () => {
const { pageIndex, pageSize } = pagination.peek()
const sort = sorting.peek()
const params = new URLSearchParams({
page: String(pageIndex),
size: String(pageSize),
...(sort.length > 0 && {
sortBy: sort[0].id,
sortDir: sort[0].desc ? 'desc' : 'asc',
}),
})
const res = await fetch(`/api/people?${params}`)
return res.json()
},
})
const table = useTable(() => ({
data: data()?.items ?? [],
columns,
pageCount: Math.ceil((data()?.total ?? 0) / pagination().pageSize),
state: {
sorting: sorting(),
pagination: pagination(),
},
onSortingChange: (updater) => {
sorting.update((prev) => (typeof updater === 'function' ? updater(prev) : updater))
},
onPaginationChange: (updater) => {
pagination.update((prev) => (typeof updater === 'function' ? updater(prev) : updater))
},
manualPagination: true,
manualSorting: true,
getCoreRowModel: getCoreRowModel(),
}))Responsive Table Patterns
Horizontal Scroll Wrapper
const TableWrapper = styled('div')`
width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
`
const StyledTable = styled('table')`
width: 100%;
min-width: 600px;
border-collapse: collapse;
th, td {
padding: 8px 12px;
text-align: left;
border-bottom: 1px solid #e0e0e0;
}
th {
background: #f5f5f5;
font-weight: 600;
position: sticky;
top: 0;
}
tr:hover td {
background: #fafafa;
}
`
// Usage
<TableWrapper>
<StyledTable>
{/* table content */}
</StyledTable>
</TableWrapper>Hide Columns on Small Screens
Use column visibility with a media query check:
function useResponsiveColumns(table) {
const isSmall = signal(window.innerWidth < 768)
window.addEventListener('resize', () => {
isSmall.set(window.innerWidth < 768)
})
effect(() => {
if (isSmall()) {
// Hide less important columns on small screens
table().getColumn('email')?.toggleVisibility(false)
table().getColumn('department')?.toggleVisibility(false)
} else {
table().getColumn('email')?.toggleVisibility(true)
table().getColumn('department')?.toggleVisibility(true)
}
})
}Full Real-World Data Table Example
A complete, production-style data table with all features combined:
import { defineComponent } from '@pyreon/core'
import { signal, computed } from '@pyreon/reactivity'
import {
useTable,
flexRender,
getCoreRowModel,
getSortedRowModel,
getFilteredRowModel,
getPaginationRowModel,
createColumnHelper,
} from '@pyreon/table'
import type { SortingState, ColumnFiltersState, PaginationState } from '@pyreon/table'
interface Employee {
id: number
name: string
email: string
department: string
role: string
salary: number
startDate: string
status: 'active' | 'inactive' | 'on-leave'
}
const columnHelper = createColumnHelper<Employee>()
const columns = [
columnHelper.display({
id: 'select',
header: ({ table }) => (
<input
type="checkbox"
checked={table.getIsAllRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
/>
),
size: 40,
}),
columnHelper.accessor('name', {
header: ({ column }) => (
<button onClick={() => column.toggleSorting()}>
Name {column.getIsSorted() === 'asc' ? '↑' : column.getIsSorted() === 'desc' ? '↓' : ''}
</button>
),
cell: (info) => <strong>{info.getValue()}</strong>,
}),
columnHelper.accessor('email', {
header: 'Email',
cell: (info) => <a href={`mailto:${info.getValue()}`}>{info.getValue()}</a>,
}),
columnHelper.accessor('department', {
header: ({ column }) => (
<button onClick={() => column.toggleSorting()}>
Department{' '}
{column.getIsSorted() === 'asc' ? '↑' : column.getIsSorted() === 'desc' ? '↓' : ''}
</button>
),
}),
columnHelper.accessor('role', { header: 'Role' }),
columnHelper.accessor('salary', {
header: ({ column }) => (
<button onClick={() => column.toggleSorting()}>
Salary {column.getIsSorted() === 'asc' ? '↑' : column.getIsSorted() === 'desc' ? '↓' : ''}
</button>
),
cell: (info) => `$${info.getValue().toLocaleString()}`,
}),
columnHelper.accessor('status', {
header: 'Status',
cell: (info) => {
const status = info.getValue()
const colors = {
active: { bg: '#dcfce7', text: '#166534' },
inactive: { bg: '#fee2e2', text: '#991b1b' },
'on-leave': { bg: '#fef9c3', text: '#854d0e' },
}
const { bg, text } = colors[status]
return (
<span
style={{
padding: '2px 8px',
borderRadius: '9999px',
fontSize: '12px',
background: bg,
color: text,
}}
>
{status}
</span>
)
},
}),
columnHelper.display({
id: 'actions',
header: '',
cell: (info) => (
<div style={{ display: 'flex', gap: '4px' }}>
<button onClick={() => console.log('Edit', info.row.original)}>Edit</button>
<button onClick={() => console.log('Delete', info.row.original.id)}>Delete</button>
</div>
),
}),
]
const EmployeeTable = defineComponent(() => {
const data = signal<Employee[]>([
/* ... employee data ... */
])
const sorting = signal<SortingState>([])
const columnFilters = signal<ColumnFiltersState>([])
const pagination = signal<PaginationState>({ pageIndex: 0, pageSize: 10 })
const rowSelection = signal<Record<string, boolean>>({})
const globalFilter = signal('')
const table = useTable(() => ({
data: data(),
columns,
state: {
sorting: sorting(),
columnFilters: columnFilters(),
pagination: pagination(),
rowSelection: rowSelection(),
globalFilter: globalFilter(),
},
onSortingChange: (u) => sorting.update((p) => (typeof u === 'function' ? u(p) : u)),
onColumnFiltersChange: (u) => columnFilters.update((p) => (typeof u === 'function' ? u(p) : u)),
onPaginationChange: (u) => pagination.update((p) => (typeof u === 'function' ? u(p) : u)),
onRowSelectionChange: (u) => rowSelection.update((p) => (typeof u === 'function' ? u(p) : u)),
onGlobalFilterChange: (u) => globalFilter.update((p) => (typeof u === 'function' ? u(p) : u)),
enableRowSelection: true,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
}))
const selectedCount = computed(() => Object.keys(table().getState().rowSelection).length)
return () => (
<div>
{/* Toolbar */}
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '8px 0' }}>
<input
type="text"
placeholder="Search all columns..."
value={globalFilter()}
onInput={(e) => globalFilter.set(e.target.value)}
style={{ padding: '6px 12px', border: '1px solid #ccc', borderRadius: '4px' }}
/>
<span>
{selectedCount()} of {table().getRowModel().rows.length} row(s) selected
</span>
</div>
{/* Table */}
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
{table()
.getHeaderGroups()
.map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th
key={header.id}
style={{
padding: '8px 12px',
textAlign: 'left',
borderBottom: '2px solid #e0e0e0',
}}
>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table()
.getRowModel()
.rows.map((row) => (
<tr key={row.id} style={{ borderBottom: '1px solid #e0e0e0' }}>
{row.getVisibleCells().map((cell) => (
<td key={cell.id} style={{ padding: '8px 12px' }}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '8px 0',
}}
>
<span style={{ fontSize: '14px', color: '#666' }}>
Showing {table().getRowModel().rows.length} of {data().length} rows
</span>
<div style={{ display: 'flex', gap: '4px' }}>
<button onClick={() => table().firstPage()} disabled={!table().getCanPreviousPage()}>
{'<<'}
</button>
<button onClick={() => table().previousPage()} disabled={!table().getCanPreviousPage()}>
{'<'}
</button>
<span style={{ padding: '0 8px' }}>
Page {table().getState().pagination.pageIndex + 1} of {table().getPageCount()}
</span>
<button onClick={() => table().nextPage()} disabled={!table().getCanNextPage()}>
{'>'}
</button>
<button onClick={() => table().lastPage()} disabled={!table().getCanNextPage()}>
{'>>'}
</button>
</div>
</div>
</div>
)
})TanStack Table Core Re-exports
All exports from @tanstack/table-core are re-exported. This includes:
Row Model Factories
getCoreRowModel-- required for all tablesgetSortedRowModel-- client-side sortinggetFilteredRowModel-- client-side filteringgetPaginationRowModel-- client-side paginationgetGroupedRowModel-- row groupinggetExpandedRowModel-- row expanding (for tree data or grouping)getFacetedRowModel-- faceted row model for filter facetsgetFacetedUniqueValues-- unique values for faceted filtersgetFacetedMinMaxValues-- min/max values for range filters
Column Helpers
createColumnHelper-- type-safe column definition helper
All Types
Table,Row,Cell,Column,Header,HeaderGroupColumnDef,ColumnDefTemplate,AccessorColumnDef,DisplayColumnDef,GroupColumnDefTableOptions,TableOptionsResolved,RowData,TableStateSortingState,ColumnFiltersState,PaginationStateVisibilityState,ExpandedState,GroupingStateColumnOrderState,RowSelectionStateUpdater,OnChangeFnAnd many more
API Reference
useTable(options)
Create a reactive TanStack Table instance.
options(() => TableOptions<TData>) -- Reactive options function. Signals read inside are automatically tracked.Returns
Computed<Table<TData>>-- A computed signal holding the table instance.
UseTableOptions<TData>
type UseTableOptions<TData extends RowData> = () => TableOptions<TData>A function returning TanStack Table options. Called reactively -- when any signal read inside changes, the table options are updated.
flexRender(component, props)
Render a TanStack Table column definition template.
component-- The column def template (string, number, function, VNode, or null).props-- The context props from TanStack Table (e.g.,header.getContext(),cell.getContext()).Returns -- The rendered output (string, number, VNode, or null).
flexRenderCell(table, rowId, columnId)
Fine-grained per-cell renderer — re-navigates to the live cell each read so an in-place data edit patches only the changed cell. Place inside an accessor: {() => flexRenderCell(table, row.id, cell.column.id)}.
table-- TheComputed<Table>accessor (fine-grained per-row subscription) or a resolvedTableinstance (coarse).rowId/columnId-- The row id (row.id) and column id (cell.column.id).Returns -- The rendered cell output, or
nullwhen the row is not in the current row model.