@toniel/laravel-tanstack-datatable-react
v0.4.0
Published
React DataTable components for Laravel pagination with TanStack Query and TanStack Table v9
Maintainers
Readme
Laravel TanStack DataTable (React)
React DataTable components for Laravel pagination, built on TanStack Query and TanStack Table v9.
This is the React port of
@toniel/laravel-tanstack-datatable
(Vue 3). Slots become render props, emits become on* callbacks, defineExpose
becomes a ref handle — everything else behaves the same.
Companion package: pair it with
@toniel/laravel-tanstack-pagination-react, which supplies the state.
Installation
npm install @toniel/laravel-tanstack-datatable-react @toniel/laravel-tanstack-pagination-react
# or
yarn add @toniel/laravel-tanstack-datatable-react @toniel/laravel-tanstack-pagination-react
# or
pnpm add @toniel/laravel-tanstack-datatable-react @toniel/laravel-tanstack-pagination-react
# or
bun add @toniel/laravel-tanstack-datatable-react @toniel/laravel-tanstack-pagination-reactPeer Dependencies
npm install react @tanstack/react-table @toniel/laravel-tanstack-pagination-react \
clsx tailwind-merge lucide-react| Peer | Range |
| --- | --- |
| react | ^18.0.0 \|\| ^19.0.0 |
| @tanstack/react-table | ^9.0.0 |
| @toniel/laravel-tanstack-pagination-react | ^0.2.0 |
| clsx | ^2.0.0 |
| tailwind-merge | ^2.0.0 \|\| ^3.0.0 |
| lucide-react | ^1.0.0 |
Requires TanStack Table v9. This package uses the v9
useTableAPI and its explicit feature registration. It will not work against v8.
The bundle carries a "use client" banner, so importing it from a Next.js App
Router server component works without wrapping it yourself.
Tailwind Setup (required)
This package ships no stylesheet — there is no style.css to import. The
components are styled with Tailwind utility classes that live inside the shipped
JavaScript, so Tailwind in your app has to scan those files or every class is
purged and the table renders unstyled.
Add the package's dist to your content globs:
// tailwind.config.js
export default {
content: [
'./src/**/*.{js,ts,jsx,tsx}',
'./node_modules/@toniel/laravel-tanstack-datatable-react/dist/**/*.{js,mjs,cjs}',
],
darkMode: 'class',
// ...
}Tailwind v4 users declare the same thing in CSS:
@import 'tailwindcss';
@source '../../node_modules/@toniel/laravel-tanstack-datatable-react/dist';shadcn/ui CSS variables are also required. The components use semantic
classes (bg-background, text-muted-foreground, border-input,
focus-visible:ring-ring, bg-accent, text-primary) that resolve through the
shadcn/ui theme variables. If your project was not set up with
shadcn/ui, define --background,
--foreground, --muted, --muted-foreground, --border, --input, --ring,
--accent, --accent-foreground, and --primary in your base layer —
otherwise those elements fall back to transparent or inherit.
Quick Start
import { useMemo } from 'react'
import { createColumnHelper } from '@tanstack/react-table'
import { usePagination } from '@toniel/laravel-tanstack-pagination-react'
import {
DataTable,
useNumberingColumn,
type DataTableFeatures,
} from '@toniel/laravel-tanstack-datatable-react'
type User = { id: number; name: string; email: string }
const columnHelper = createColumnHelper<DataTableFeatures, User>()
async function fetchUsers(filters) {
const params = new URLSearchParams(filters as Record<string, string>)
const response = await fetch(`/api/users?${params}`)
if (!response.ok) throw new Error('Failed to load users')
return response.json()
}
export function UsersTable() {
const {
tableData,
pagination,
isLoading,
error,
search,
perPage,
sortBy,
sortDirection,
handlePageChange,
handlePerPageChange,
handleSearchChange,
handleSortChange,
refetch,
} = usePagination<User>(fetchUsers, { queryKey: 'users' })
const numbering = useNumberingColumn<User>({ pagination, perPage, header: '#' })
const columns = useMemo(
() => [
numbering,
columnHelper.accessor('name', { header: 'Name' }),
columnHelper.accessor('email', { header: 'Email' }),
],
[numbering],
)
return (
<DataTable
data={tableData}
columns={columns}
pagination={pagination}
isLoading={isLoading}
error={error}
search={search}
currentPerPage={perPage}
sortBy={sortBy}
sortDirection={sortDirection}
onPageChange={handlePageChange}
onPerPageChange={handlePerPageChange}
onSearchChange={handleSearchChange}
onSortChange={handleSortChange}
onRetry={() => refetch()}
/>
)
}createColumnHelper takes the feature set first in v9 — see
Column typing.
Features
- Sortable headers as real
<button>s, witharia-sorton the<th> - Loading, error and empty states, plus a non-blocking overlay on page changes
- Row selection across pages, with off-page awareness
- Built-in pagination footer with an ellipsised page range and a page-size select
- Dark mode via Tailwind's
dark:classes - Fully typed, tree-shakeable, SSR-safe
Component Props
DataTable
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| data | any[] | [] | Rows for the current page |
| columns | ColumnDef<DataTableFeatures, any>[] | — | Column definitions (required) |
| pagination | LaravelPaginationResponse \| null | null | Laravel pagination payload |
| isLoading | boolean | false | Shows the loading state / overlay |
| error | Error \| null | null | Shows the error state |
| search | string | '' | Controlled search value |
| currentPerPage | number | 10 | Selected page size |
| perPageOptions | number[] | [10, 15, 25, 50, 100] | Page-size choices |
| sortBy | string \| null | null | Sorted column id |
| sortDirection | 'asc' \| 'desc' | 'asc' | Sort direction |
| filters | Record<string, any> | {} | Handed to renderFilters |
| selection | RowSelection | — | The store from useRowSelection(). Passing it turns selection on |
| selectionColumn | boolean \| SelectionColumnOptions | true | false to render your own checkbox column |
| showSelectionInfo | boolean | true | Shows the default selection banner |
| getRowId | (row: any) => string \| number | row => row.id | Row identity |
| showSearch | boolean | true | Shows the search input |
| showPerPageSelector | boolean | true | Shows the page-size select |
| rowClassName | string \| ((row: any) => string) | '' | Extra classes per row |
| classNames | DataTableClassNames | {} | Per-part class overrides — see Styling |
| labels | DataTableLabels | see below | Every user-facing string |
Callbacks
| Prop | Signature | Fired when |
| --- | --- | --- |
| onPageChange | (page: number) => void | A page button is clicked |
| onPerPageChange | (perPage: number) => void | The page size changes |
| onSearchChange | (search: string) => void | Every keystroke — this component does not debounce |
| onSortChange | (column: string) => void | A sortable header is clicked |
| onFilterChange | (filters: Record<string, any>) => void | Called by your own renderFilters UI |
| onRetry | () => void | The retry button in the error state |
onSearchChange fires unthrottled by design; usePagination's
handleSearchChange already debounces before it issues a request.
labels
One object instead of six text props, so translating a table happens in one place:
| Key | Default |
| --- | --- |
| loading | 'Loading...' |
| error | 'Error loading data' |
| retry | 'Retry' |
| empty | 'No items found' |
| searchPlaceholder | 'Search...' |
| searchLabel | 'Search' |
| itemName | 'items' |
| clearSelection | 'Clear selection' |
| selectionSummary | (count, itemName) => \${count} ${itemName} selected`` |
<DataTable
labels={{
loading: 'Memuat user',
empty: 'Tidak ada user',
retry: 'Coba lagi',
itemName: 'user',
selectionSummary: (count) => `${count} user dipilih`,
}}
/>Render props (the Vue package's slots)
| Prop | Type | Replaces |
| --- | --- | --- |
| renderFilters | (ctx: FiltersContext) => ReactNode | filters slot |
| header | ReactNode | header slot |
| renderSelectionInfo | (ctx: SelectionContext) => ReactNode | selection-info slot |
| renderBulkActions | (ctx: SelectionContext) => ReactNode | bulk-actions slot |
interface FiltersContext {
filters: Record<string, any>
onFilterChange: (filters: Record<string, any>) => void
}
interface SelectionContext<TData> {
selectedIds: string[] // every page
selectedCount: number // every page
selectedRowsOnPage: TData[] // only the rows on screen
hasOffPageSelection: boolean
clearSelection: () => void
selectAllOnPage: () => void
deselectAllOnPage: () => void
}renderSelectionInfo replaces the whole banner; renderBulkActions keeps the
banner and swaps only the buttons on its right.
<DataTable
// …
header={<button onClick={openCreateModal}>Add user</button>}
renderFilters={({ filters, onFilterChange }) => (
<select
value={filters.status ?? ''}
onChange={(e) => onFilterChange({ ...filters, status: e.target.value })}
>
<option value="">All</option>
<option value="active">Active</option>
</select>
)}
renderBulkActions={({ selectedIds, clearSelection }) => (
<>
<button onClick={() => bulkDelete(selectedIds)}>Delete {selectedIds.length}</button>
<button onClick={clearSelection}>Clear</button>
</>
)}
/>Imperative handle
import { useRef } from 'react'
import type { DataTableHandle } from '@toniel/laravel-tanstack-datatable-react'
const tableRef = useRef<DataTableHandle>(null)
tableRef.current?.selectAllOnPage()
tableRef.current?.clearSelection()
tableRef.current?.table // the raw TanStack Table instanceDataTableHandle extends SelectionContext, so it carries exactly what the
selection render props get — selectedIds, selectedCount,
selectedRowsOnPage, hasOffPageSelection, deselectAllOnPage — plus
table. The two cannot drift apart.
Row Selection
useRowSelection holds which ids are selected; DataTable does everything that
needs to know which rows are on screen. Passing the store turns selection on and
adds the checkbox column:
import { useRowSelection, DataTable } from '@toniel/laravel-tanstack-datatable-react'
function UsersTable() {
const { tableData } = usePagination<User>(fetchUsers, { queryKey: 'users' })
const selection = useRowSelection()
return (
<>
<DataTable
data={tableData}
columns={columns}
selection={selection}
labels={{ itemName: 'users' }}
renderBulkActions={({ selectedIds, clearSelection }) => (
<>
<button onClick={() => destroy(selectedIds)}>Delete {selectedIds.length}</button>
<button onClick={clearSelection}>Clear</button>
</>
)}
/>
<p>{selection.count} selected</p>
</>
)
}columns needs no entry for the checkbox and no memo dependency on one.
useRowSelection(options?)
| Option | Type | Default |
| --- | --- | --- |
| initial | RowSelectionState | {} |
| Returns | Type | Description |
| --- | --- | --- |
| rowSelection | RowSelectionState | The map. Hand it to DataTable as selection |
| setRowSelection | Dispatch<SetStateAction<RowSelectionState>> | |
| selectedIds | string[] | Every selected id, across every page |
| count | number | |
| isSelected | (id: string) => boolean | |
| select / deselect | (ids: string[]) => void | |
| clear | () => void | |
The hook takes no rows. Everything that needs them — select-all-on-page, the
indeterminate header checkbox, which visible rows are selected — is on the table
and reaches you through renderBulkActions, renderSelectionInfo or the ref
handle.
Selection spans pages, rows do not.
selectedIdsandselectedCountcover every page, because the map outlives a page change.selectedRowsOnPagecan only cover rows the table was handed. Act on the ids when you bulk-delete or bulk-update;hasOffPageSelectiontells you when the rows are an incomplete view.
The checkbox column
Prepended automatically. selectionColumn={false} turns it off so you can
supply your own; an object restyles the built-in one:
<DataTable
selection={selection}
selectionColumn={{
checkboxClassName: 'accent-sky-600',
selectAllLabel: 'Pilih semua baris di halaman ini',
selectRowLabel: (id) => `Pilih baris ${id}`,
}}
/>It reads and writes through the table instance (row.getIsSelected(),
table.getToggleAllPageRowsSelectedHandler()), so a click and a
selection.select([...]) land in the same place.
Row numbering
useNumberingColumn builds a column that numbers rows continuously across
pages — page 2 at 10 per page starts at 11:
import { useNumberingColumn } from '@toniel/laravel-tanstack-datatable-react'
const numbering = useNumberingColumn<User>({ pagination, perPage, header: 'No.' })
const columns = useMemo(() => [numbering, ...rest], [numbering])| Option | Type | Default |
| --- | --- | --- |
| pagination | LaravelPaginationResponse \| null | null |
| perPage | number | 10 |
| header | string | 'No.' |
| className | string | '' |
| width | string | '60px' |
| enableSorting | boolean | false |
It is memoised on the page offset, not on the payload, so a refetch that
returns the same page does not rebuild the column — and therefore does not
rebuild the table under it. That makes it a sound dependency for the useMemo
around your columns.
Moved here from the pagination package in 0.3.0. Building a column means rendering an element, which a headless hooks package should not do; it also had to return a structural type you re-cast at every call site.
Column typing
v9 gates its APIs behind explicitly registered features, so column types carry a
TFeatures generic. This package exports its feature set:
import { createColumnHelper, type ColumnDef } from '@tanstack/react-table'
import type { DataTableFeatures } from '@toniel/laravel-tanstack-datatable-react'
const columnHelper = createColumnHelper<DataTableFeatures, User>()
const columns: ColumnDef<DataTableFeatures, User>[] = [
columnHelper.accessor('name', { header: 'Name' }),
]The registered features are rowSelectionFeature, rowSortingFeature and
columnVisibilityFeature — exactly what DataTable uses. The core row model is
automatic in v9; there is no getCoreRowModel() option.
Row types must be objects or arrays; a table of bare strings or numbers will not type-check.
Custom cells
const columns = useMemo(
() => [
columnHelper.accessor('name', { header: 'Name' }),
columnHelper.display({
id: 'actions',
header: 'Actions',
cell: ({ row }) => (
<div className="flex gap-2">
<button onClick={() => edit(row.original)}>Edit</button>
<button onClick={() => destroy(row.original.id)}>Delete</button>
</div>
),
}),
],
[],
)Styling
Per-row classes
// Static
<DataTable rowClassName="hover:bg-slate-50" />
// Conditional
<DataTable
rowClassName={(row) =>
row.status === 'inactive' ? 'opacity-60' : row.flagged ? 'bg-red-50' : ''
}
/>classNames
Every part of the table takes an override. Values are merged with the
component's own classes through tailwind-merge, so a utility in the same
group replaces the built-in one instead of fighting it in the cascade:
<DataTable
classNames={{
root: 'p-0', // removes the default p-4
toolbar: 'justify-end', // not appended to justify-between — replaces it
card: 'overflow-hidden rounded-xl',
headerCell: 'font-semibold',
pagination: 'bg-slate-50',
}}
/>The full set:
| Key | Element |
| --- | --- |
| root | Outermost wrapper (holds the padding and vertical rhythm) |
| toolbar | The row holding search, filters and header |
| search / searchInput | Search wrapper / the <input> |
| filters | Wrapper around what renderFilters returns |
| header | The right-aligned header area |
| selectionInfo | The built-in selection banner |
| card | The bordered card around the table |
| scroller | The horizontal scroll container |
| table / thead / tbody / tfoot | The table elements |
| headerRow / headerCell / sortButton | Header row, cell, and the sort <button> |
| row / cell | Body row and cell |
| loading / loadingOverlay | First-load state / reload overlay |
| error / empty | Error state / empty-state cell |
| pagination | Forwarded to the pagination footer's root |
classNames.row applies to every row and rowClassName is evaluated per row;
when both are set, the per-row decision wins.
Why this exists. The first consumers of the Vue package reached for scoped-CSS
:deep()selectors to undo this component's padding and to hide the toolbar. React has no:deep(), so without these keys there would be no way out at all. The empty toolbar is also no longer rendered, which was the other half of that workaround.
Dark Mode
The components already carry dark: variants. Set darkMode: 'class' in your
Tailwind config and toggle the dark class on <html>. useDarkMode does that
for you, following the OS preference:
import { useDarkMode } from '@toniel/laravel-tanstack-datatable-react'
const { isDarkMode, toggleDarkMode } = useDarkMode()It starts as light and syncs after mount, so server and client markup agree during hydration.
Also exported
cn(...classes)—clsx+tailwind-mergeDataTablePagination— the pagination footer on its owndataTableFeatures/DataTableFeatures
A working example
koperasi-app in this workspace wires the whole stack end to end and is the
reference to read when a snippet here is not enough:
| Piece | File |
| --- | --- |
| Service (axios, one per resource) | resources/js/services/userService.ts |
| Query hook | resources/js/hooks/useUsersQuery.ts |
| Table and columns | resources/js/components/users/UsersTable.tsx |
| Filter control | resources/js/components/users/UsersFilters.tsx |
| Page | resources/js/pages/users/Index.tsx |
| QueryClientProvider wiring | resources/js/app.tsx |
| Tailwind @source + theme tokens | resources/css/app.css |
| Laravel endpoint | app/Http/Controllers/Api/UserController.php |
Its CRUD_FLOW.md is the guide for building the next resource the same way.
Development
The companion pagination package is linked from a sibling checkout:
"devDependencies": {
"@toniel/laravel-tanstack-pagination-react": "file:../laravel-tanstack-pagination-react"
}Clone both repositories side by side, build the pagination package first, then:
npm install
npm run typecheck # tsc over src/ and tests/
npm test # vitest, jsdom + Testing Library
npm run buildnpm run typecheck covers the tests too, which is what makes the
@ts-expect-error assertions in them real: one of the tests asserts that the
removed title prop is still rejected, and it fails if the prop comes back.
Note that file: linking gives each package its own copy of React, which React
rejects at runtime. Deduplicate before running an app against the linked build
(npm link the peer, or point both at one node_modules/react).
Related Packages
@toniel/laravel-tanstack-pagination-react— core hooks (required)@toniel/laravel-tanstack-datatable— the Vue 3 original@tanstack/react-query@tanstack/react-table
