npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@toniel/laravel-tanstack-datatable-react

v0.4.0

Published

React DataTable components for Laravel pagination with TanStack Query and TanStack Table v9

Readme

Laravel TanStack DataTable (React)

npm version npm downloads

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-react

Peer 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 useTable API 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, with aria-sort on 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 instance

DataTableHandle 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. selectedIds and selectedCount cover every page, because the map outlives a page change. selectedRowsOnPage can only cover rows the table was handed. Act on the ids when you bulk-delete or bulk-update; hasOffPageSelection tells 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-merge
  • DataTablePagination — the pagination footer on its own
  • dataTableFeatures / 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 build

npm 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

📄 License

MIT © Toniel

📮 Contact