From 42b8e73cff111666051c35dcabf9357b389b0ec2 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 15 Jun 2026 16:30:22 -0400 Subject: [PATCH 1/5] feat(headless): add useDataTable hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a server-driven data table state hook to @clerk/headless with sorting, column/global filtering, pagination, and row selection. Data passes through unchanged — consumers wire onChange callbacks to their own fetch layer. --- .changeset/hip-ghosts-notice.md | 2 + packages/headless/src/hooks/index.ts | 12 + .../headless/src/hooks/use-data-table.test.ts | 371 ++++++++++++++++++ packages/headless/src/hooks/use-data-table.ts | 217 ++++++++++ 4 files changed, 602 insertions(+) create mode 100644 .changeset/hip-ghosts-notice.md create mode 100644 packages/headless/src/hooks/use-data-table.test.ts create mode 100644 packages/headless/src/hooks/use-data-table.ts diff --git a/.changeset/hip-ghosts-notice.md b/.changeset/hip-ghosts-notice.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/hip-ghosts-notice.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/headless/src/hooks/index.ts b/packages/headless/src/hooks/index.ts index fcf2a1dcf28..f75839cbe8a 100644 --- a/packages/headless/src/hooks/index.ts +++ b/packages/headless/src/hooks/index.ts @@ -1,4 +1,16 @@ export { useAnimationsFinished } from './use-animations-finished'; +export { useDataTable } from './use-data-table'; +export type { + ColumnFiltersState, + DataTableRow, + OnChangeFn, + PaginationState, + RowSelectionState, + SortingState, + Updater, + UseDataTableOptions, + UseDataTableReturn, +} from './use-data-table'; export { useControllableState } from './use-controllable-state'; export { type TransitionProps, diff --git a/packages/headless/src/hooks/use-data-table.test.ts b/packages/headless/src/hooks/use-data-table.test.ts new file mode 100644 index 00000000000..04335c611be --- /dev/null +++ b/packages/headless/src/hooks/use-data-table.test.ts @@ -0,0 +1,371 @@ +import { act, renderHook } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { useDataTable } from './use-data-table'; + +type Person = { id: number; name: string; role: string }; + +const DATA: Person[] = [ + { id: 1, name: 'Alice', role: 'Admin' }, + { id: 2, name: 'Bob', role: 'Member' }, + { id: 3, name: 'Carol', role: 'Admin' }, +]; + +describe('useDataTable', () => { + describe('rows', () => { + it('maps data to enriched row objects with stable string ids', () => { + const { result } = renderHook(() => useDataTable({ data: DATA })); + + expect(result.current.rows).toHaveLength(3); + expect(result.current.rows[0].id).toBe('0'); + expect(result.current.rows[1].id).toBe('1'); + expect(result.current.rows[2].id).toBe('2'); + }); + + it('exposes original data on each row', () => { + const { result } = renderHook(() => useDataTable({ data: DATA })); + + expect(result.current.rows[0].original).toBe(DATA[0]); + expect(result.current.rows[2].original).toBe(DATA[2]); + }); + + it('updates when data changes', () => { + const { result, rerender } = renderHook(({ data }) => useDataTable({ data }), { + initialProps: { data: DATA }, + }); + + expect(result.current.rows).toHaveLength(3); + + rerender({ data: DATA.slice(0, 2) }); + + expect(result.current.rows).toHaveLength(2); + }); + + it('returns empty rows for empty data', () => { + const { result } = renderHook(() => useDataTable({ data: [] })); + + expect(result.current.rows).toHaveLength(0); + }); + }); + + describe('sorting', () => { + it('defaults to empty sorting state', () => { + const { result } = renderHook(() => useDataTable({ data: DATA })); + + expect(result.current.sorting).toEqual([]); + }); + + it('uses defaultSorting for initial state', () => { + const { result } = renderHook(() => useDataTable({ data: DATA, defaultSorting: [{ id: 'name', desc: false }] })); + + expect(result.current.sorting).toEqual([{ id: 'name', desc: false }]); + }); + + it('updates sorting via setSorting with a value', () => { + const { result } = renderHook(() => useDataTable({ data: DATA })); + + act(() => result.current.setSorting([{ id: 'name', desc: true }])); + + expect(result.current.sorting).toEqual([{ id: 'name', desc: true }]); + }); + + it('updates sorting via setSorting with a functional updater', () => { + const { result } = renderHook(() => useDataTable({ data: DATA, defaultSorting: [{ id: 'name', desc: false }] })); + + act(() => result.current.setSorting(old => [{ ...old[0], desc: true }])); + + expect(result.current.sorting).toEqual([{ id: 'name', desc: true }]); + }); + + it('fires onSortingChange with resolved value', () => { + const onSortingChange = vi.fn(); + const { result } = renderHook(() => useDataTable({ data: DATA, onSortingChange })); + + act(() => result.current.setSorting([{ id: 'role', desc: false }])); + + expect(onSortingChange).toHaveBeenCalledWith([{ id: 'role', desc: false }]); + }); + + it('respects controlled sorting prop', () => { + const controlled = [{ id: 'name', desc: false }]; + const { result } = renderHook(() => useDataTable({ data: DATA, sorting: controlled })); + + act(() => result.current.setSorting([])); + + // Stays controlled — internal state does not override + expect(result.current.sorting).toEqual(controlled); + }); + }); + + describe('columnFilters', () => { + it('defaults to empty filters', () => { + const { result } = renderHook(() => useDataTable({ data: DATA })); + + expect(result.current.columnFilters).toEqual([]); + }); + + it('sets a filter with a value', () => { + const { result } = renderHook(() => useDataTable({ data: DATA })); + + act(() => result.current.setColumnFilters([{ id: 'role', value: 'Admin' }])); + + expect(result.current.columnFilters).toEqual([{ id: 'role', value: 'Admin' }]); + }); + + it('sets a filter with a functional updater', () => { + const { result } = renderHook(() => + useDataTable({ data: DATA, defaultColumnFilters: [{ id: 'role', value: 'Admin' }] }), + ); + + act(() => result.current.setColumnFilters(old => [...old, { id: 'name', value: 'Alice' }])); + + expect(result.current.columnFilters).toEqual([ + { id: 'role', value: 'Admin' }, + { id: 'name', value: 'Alice' }, + ]); + }); + + it('fires onColumnFiltersChange with resolved value', () => { + const onColumnFiltersChange = vi.fn(); + const { result } = renderHook(() => useDataTable({ data: DATA, onColumnFiltersChange })); + + act(() => result.current.setColumnFilters([{ id: 'role', value: 'Member' }])); + + expect(onColumnFiltersChange).toHaveBeenCalledWith([{ id: 'role', value: 'Member' }]); + }); + }); + + describe('globalFilter', () => { + it('defaults to empty string', () => { + const { result } = renderHook(() => useDataTable({ data: DATA })); + + expect(result.current.globalFilter).toBe(''); + }); + + it('sets globalFilter', () => { + const { result } = renderHook(() => useDataTable({ data: DATA })); + + act(() => result.current.setGlobalFilter('alice')); + + expect(result.current.globalFilter).toBe('alice'); + }); + + it('fires onGlobalFilterChange', () => { + const onGlobalFilterChange = vi.fn(); + const { result } = renderHook(() => useDataTable({ data: DATA, onGlobalFilterChange })); + + act(() => result.current.setGlobalFilter('bob')); + + expect(onGlobalFilterChange).toHaveBeenCalledWith('bob'); + }); + }); + + describe('pagination', () => { + it('defaults to pageIndex 0 and pageSize 10', () => { + const { result } = renderHook(() => useDataTable({ data: DATA })); + + expect(result.current.pagination).toEqual({ pageIndex: 0, pageSize: 10 }); + }); + + it('uses defaultPagination for initial state', () => { + const { result } = renderHook(() => + useDataTable({ data: DATA, defaultPagination: { pageIndex: 2, pageSize: 25 } }), + ); + + expect(result.current.pagination).toEqual({ pageIndex: 2, pageSize: 25 }); + }); + + it('getPageCount returns 0 when totalCount is undefined', () => { + const { result } = renderHook(() => useDataTable({ data: DATA })); + + expect(result.current.getPageCount()).toBe(0); + }); + + it('getPageCount computes from totalCount and pageSize', () => { + const { result } = renderHook(() => + useDataTable({ data: DATA, totalCount: 55, defaultPagination: { pageIndex: 0, pageSize: 10 } }), + ); + + expect(result.current.getPageCount()).toBe(6); + }); + + it('getCanNextPage is false on the last page', () => { + const { result } = renderHook(() => + useDataTable({ + data: DATA, + totalCount: 10, + defaultPagination: { pageIndex: 0, pageSize: 10 }, + }), + ); + + expect(result.current.getCanNextPage()).toBe(false); + }); + + it('getCanNextPage is true when more pages exist', () => { + const { result } = renderHook(() => + useDataTable({ + data: DATA, + totalCount: 20, + defaultPagination: { pageIndex: 0, pageSize: 10 }, + }), + ); + + expect(result.current.getCanNextPage()).toBe(true); + }); + + it('getCanPreviousPage is false on page 0', () => { + const { result } = renderHook(() => useDataTable({ data: DATA })); + + expect(result.current.getCanPreviousPage()).toBe(false); + }); + + it('getCanPreviousPage is true on page > 0', () => { + const { result } = renderHook(() => + useDataTable({ data: DATA, defaultPagination: { pageIndex: 1, pageSize: 10 } }), + ); + + expect(result.current.getCanPreviousPage()).toBe(true); + }); + + it('nextPage increments pageIndex', () => { + const { result } = renderHook(() => + useDataTable({ + data: DATA, + totalCount: 30, + defaultPagination: { pageIndex: 0, pageSize: 10 }, + }), + ); + + act(() => result.current.nextPage()); + + expect(result.current.pagination.pageIndex).toBe(1); + }); + + it('previousPage decrements pageIndex', () => { + const { result } = renderHook(() => + useDataTable({ data: DATA, defaultPagination: { pageIndex: 2, pageSize: 10 } }), + ); + + act(() => result.current.previousPage()); + + expect(result.current.pagination.pageIndex).toBe(1); + }); + + it('setPageSize updates pageSize and resets pageIndex to 0', () => { + const { result } = renderHook(() => + useDataTable({ data: DATA, defaultPagination: { pageIndex: 3, pageSize: 10 } }), + ); + + act(() => result.current.setPageSize(25)); + + expect(result.current.pagination).toEqual({ pageIndex: 0, pageSize: 25 }); + }); + + it('fires onPaginationChange when page changes', () => { + const onPaginationChange = vi.fn(); + const { result } = renderHook(() => + useDataTable({ + data: DATA, + totalCount: 30, + defaultPagination: { pageIndex: 0, pageSize: 10 }, + onPaginationChange, + }), + ); + + act(() => result.current.nextPage()); + + expect(onPaginationChange).toHaveBeenCalledWith({ pageIndex: 1, pageSize: 10 }); + }); + }); + + describe('row selection', () => { + it('defaults to no rows selected', () => { + const { result } = renderHook(() => useDataTable({ data: DATA })); + + expect(result.current.rowSelection).toEqual({}); + }); + + it('row.getIsSelected() returns false by default', () => { + const { result } = renderHook(() => useDataTable({ data: DATA })); + + expect(result.current.rows[0].getIsSelected()).toBe(false); + }); + + it('row.toggleSelected() selects the row', () => { + const { result } = renderHook(() => useDataTable({ data: DATA })); + + act(() => result.current.rows[0].toggleSelected()); + + expect(result.current.rows[0].getIsSelected()).toBe(true); + expect(result.current.rowSelection).toEqual({ '0': true }); + }); + + it('row.toggleSelected() deselects an already-selected row', () => { + const { result } = renderHook(() => useDataTable({ data: DATA, defaultRowSelection: { '0': true } })); + + act(() => result.current.rows[0].toggleSelected()); + + expect(result.current.rows[0].getIsSelected()).toBe(false); + }); + + it('getIsAllRowsSelected() returns false when no rows selected', () => { + const { result } = renderHook(() => useDataTable({ data: DATA })); + + expect(result.current.getIsAllRowsSelected()).toBe(false); + }); + + it('getIsAllRowsSelected() returns true when all rows selected', () => { + const { result } = renderHook(() => + useDataTable({ data: DATA, defaultRowSelection: { '0': true, '1': true, '2': true } }), + ); + + expect(result.current.getIsAllRowsSelected()).toBe(true); + }); + + it('getIsAllRowsSelected() returns false for empty data', () => { + const { result } = renderHook(() => useDataTable({ data: [] })); + + expect(result.current.getIsAllRowsSelected()).toBe(false); + }); + + it('getIsSomeRowsSelected() returns true on partial selection', () => { + const { result } = renderHook(() => useDataTable({ data: DATA, defaultRowSelection: { '1': true } })); + + expect(result.current.getIsSomeRowsSelected()).toBe(true); + }); + + it('getIsSomeRowsSelected() returns false when nothing selected', () => { + const { result } = renderHook(() => useDataTable({ data: DATA })); + + expect(result.current.getIsSomeRowsSelected()).toBe(false); + }); + + it('toggleAllRowsSelected() selects all rows', () => { + const { result } = renderHook(() => useDataTable({ data: DATA })); + + act(() => result.current.toggleAllRowsSelected()); + + expect(result.current.getIsAllRowsSelected()).toBe(true); + expect(result.current.rowSelection).toEqual({ '0': true, '1': true, '2': true }); + }); + + it('toggleAllRowsSelected() deselects all when all are selected', () => { + const { result } = renderHook(() => + useDataTable({ data: DATA, defaultRowSelection: { '0': true, '1': true, '2': true } }), + ); + + act(() => result.current.toggleAllRowsSelected()); + + expect(result.current.rowSelection).toEqual({}); + }); + + it('fires onRowSelectionChange when selection changes', () => { + const onRowSelectionChange = vi.fn(); + const { result } = renderHook(() => useDataTable({ data: DATA, onRowSelectionChange })); + + act(() => result.current.rows[1].toggleSelected()); + + expect(onRowSelectionChange).toHaveBeenCalledWith({ '1': true }); + }); + }); +}); diff --git a/packages/headless/src/hooks/use-data-table.ts b/packages/headless/src/hooks/use-data-table.ts new file mode 100644 index 00000000000..333bd6e36ae --- /dev/null +++ b/packages/headless/src/hooks/use-data-table.ts @@ -0,0 +1,217 @@ +'use client'; + +import { useCallback, useMemo } from 'react'; +import { useControllableState } from './use-controllable-state'; + +export type Updater = T | ((old: T) => T); +export type OnChangeFn = (updaterOrValue: Updater) => void; + +export type SortingState = Array<{ id: string; desc: boolean }>; +export type ColumnFiltersState = Array<{ id: string; value: unknown }>; +export type PaginationState = { pageIndex: number; pageSize: number }; +export type RowSelectionState = Record; + +function functionalUpdate(updater: Updater, old: T): T { + return typeof updater === 'function' ? (updater as (old: T) => T)(old) : updater; +} + +export interface UseDataTableOptions { + data: TData[]; + totalCount?: number; + + sorting?: SortingState; + defaultSorting?: SortingState; + onSortingChange?: OnChangeFn; + + columnFilters?: ColumnFiltersState; + defaultColumnFilters?: ColumnFiltersState; + onColumnFiltersChange?: OnChangeFn; + + globalFilter?: string; + defaultGlobalFilter?: string; + onGlobalFilterChange?: OnChangeFn; + + pagination?: PaginationState; + defaultPagination?: PaginationState; + onPaginationChange?: OnChangeFn; + + rowSelection?: RowSelectionState; + defaultRowSelection?: RowSelectionState; + onRowSelectionChange?: OnChangeFn; +} + +export interface DataTableRow { + id: string; + original: TData; + getIsSelected: () => boolean; + toggleSelected: () => void; +} + +export interface UseDataTableReturn { + rows: DataTableRow[]; + + sorting: SortingState; + setSorting: OnChangeFn; + + columnFilters: ColumnFiltersState; + setColumnFilters: OnChangeFn; + + globalFilter: string; + setGlobalFilter: OnChangeFn; + + pagination: PaginationState; + setPagination: OnChangeFn; + nextPage: () => void; + previousPage: () => void; + setPageSize: (size: number) => void; + getCanNextPage: () => boolean; + getCanPreviousPage: () => boolean; + getPageCount: () => number; + + rowSelection: RowSelectionState; + setRowSelection: OnChangeFn; + getIsAllRowsSelected: () => boolean; + getIsSomeRowsSelected: () => boolean; + toggleAllRowsSelected: () => void; +} + +export function useDataTable(opts: UseDataTableOptions): UseDataTableReturn { + // ── State slices ──────────────────────────────────────────────────────────── + + const [sorting, setSortingRaw] = useControllableState( + opts.sorting, + opts.defaultSorting ?? [], + opts.onSortingChange ? (v: SortingState) => opts.onSortingChange!(v) : undefined, + ); + + const [columnFilters, setColumnFiltersRaw] = useControllableState( + opts.columnFilters, + opts.defaultColumnFilters ?? [], + opts.onColumnFiltersChange ? (v: ColumnFiltersState) => opts.onColumnFiltersChange!(v) : undefined, + ); + + const [globalFilter, setGlobalFilterRaw] = useControllableState( + opts.globalFilter, + opts.defaultGlobalFilter ?? '', + opts.onGlobalFilterChange ? (v: string) => opts.onGlobalFilterChange!(v) : undefined, + ); + + const [pagination, setPaginationRaw] = useControllableState( + opts.pagination, + opts.defaultPagination ?? { pageIndex: 0, pageSize: 10 }, + opts.onPaginationChange ? (v: PaginationState) => opts.onPaginationChange!(v) : undefined, + ); + + const [rowSelection, setRowSelectionRaw] = useControllableState( + opts.rowSelection, + opts.defaultRowSelection ?? {}, + opts.onRowSelectionChange ? (v: RowSelectionState) => opts.onRowSelectionChange!(v) : undefined, + ); + + // ── Updater-aware public setters ──────────────────────────────────────────── + + const setSorting: OnChangeFn = useCallback( + u => setSortingRaw(functionalUpdate(u, sorting)), + [sorting, setSortingRaw], + ); + + const setColumnFilters: OnChangeFn = useCallback( + u => setColumnFiltersRaw(functionalUpdate(u, columnFilters)), + [columnFilters, setColumnFiltersRaw], + ); + + const setGlobalFilter: OnChangeFn = useCallback( + u => setGlobalFilterRaw(functionalUpdate(u, globalFilter)), + [globalFilter, setGlobalFilterRaw], + ); + + const setPagination: OnChangeFn = useCallback( + u => setPaginationRaw(functionalUpdate(u, pagination)), + [pagination, setPaginationRaw], + ); + + const setRowSelection: OnChangeFn = useCallback( + u => setRowSelectionRaw(functionalUpdate(u, rowSelection)), + [rowSelection, setRowSelectionRaw], + ); + + // ── Rows ──────────────────────────────────────────────────────────────────── + + const rows = useMemo[]>( + () => + opts.data.map((original, i) => { + const id = String(i); + return { + id, + original, + getIsSelected: () => !!rowSelection[id], + toggleSelected: () => setRowSelection(old => ({ ...old, [id]: !old[id] })), + }; + }), + [opts.data, rowSelection, setRowSelection], + ); + + // ── Pagination helpers ────────────────────────────────────────────────────── + + const getPageCount = useCallback( + () => (opts.totalCount != null ? Math.ceil(opts.totalCount / pagination.pageSize) : 0), + [opts.totalCount, pagination.pageSize], + ); + + const getCanNextPage = useCallback( + () => pagination.pageIndex < getPageCount() - 1, + [pagination.pageIndex, getPageCount], + ); + + const getCanPreviousPage = useCallback(() => pagination.pageIndex > 0, [pagination.pageIndex]); + + const nextPage = useCallback(() => setPagination(p => ({ ...p, pageIndex: p.pageIndex + 1 })), [setPagination]); + + const previousPage = useCallback(() => setPagination(p => ({ ...p, pageIndex: p.pageIndex - 1 })), [setPagination]); + + const setPageSize = useCallback( + (size: number) => setPagination(() => ({ pageIndex: 0, pageSize: size })), + [setPagination], + ); + + // ── Selection helpers ─────────────────────────────────────────────────────── + + const getIsAllRowsSelected = useCallback( + () => rows.length > 0 && rows.every(r => !!rowSelection[r.id]), + [rows, rowSelection], + ); + + const getIsSomeRowsSelected = useCallback(() => rows.some(r => !!rowSelection[r.id]), [rows, rowSelection]); + + const toggleAllRowsSelected = useCallback(() => { + setRowSelection(getIsAllRowsSelected() ? {} : Object.fromEntries(rows.map(r => [r.id, true]))); + }, [rows, getIsAllRowsSelected, setRowSelection]); + + return { + rows, + + sorting, + setSorting, + + columnFilters, + setColumnFilters, + + globalFilter, + setGlobalFilter, + + pagination, + setPagination, + nextPage, + previousPage, + setPageSize, + getCanNextPage, + getCanPreviousPage, + getPageCount, + + rowSelection, + setRowSelection, + getIsAllRowsSelected, + getIsSomeRowsSelected, + toggleAllRowsSelected, + }; +} From 27d9eca27df90443481c129961a0427c27cb3b65 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 15 Jun 2026 16:46:13 -0400 Subject: [PATCH 2/5] fix(headless): address coderabbit feedback on useDataTable - add getRowId option for stable row IDs across server-driven pages/sorts - clamp nextPage to last page, previousPage to 0, guard setPageSize > 0 - fix getIsSomeRowsSelected to return false when all rows selected - add boundary tests for nextPage/previousPage at limits - add controlled-mode tests for columnFilters, globalFilter, pagination, rowSelection --- .../headless/src/hooks/use-data-table.test.ts | 70 +++++++++++++++++++ packages/headless/src/hooks/use-data-table.ts | 27 +++++-- 2 files changed, 91 insertions(+), 6 deletions(-) diff --git a/packages/headless/src/hooks/use-data-table.test.ts b/packages/headless/src/hooks/use-data-table.test.ts index 04335c611be..ef3b74061e9 100644 --- a/packages/headless/src/hooks/use-data-table.test.ts +++ b/packages/headless/src/hooks/use-data-table.test.ts @@ -1,6 +1,7 @@ import { act, renderHook } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; +import type { PaginationState } from './use-data-table'; import { useDataTable } from './use-data-table'; type Person = { id: number; name: string; role: string }; @@ -22,6 +23,14 @@ describe('useDataTable', () => { expect(result.current.rows[2].id).toBe('2'); }); + it('uses getRowId for stable ids when provided', () => { + const { result } = renderHook(() => useDataTable({ data: DATA, getRowId: row => String(row.id) })); + + expect(result.current.rows[0].id).toBe('1'); + expect(result.current.rows[1].id).toBe('2'); + expect(result.current.rows[2].id).toBe('3'); + }); + it('exposes original data on each row', () => { const { result } = renderHook(() => useDataTable({ data: DATA })); @@ -133,6 +142,15 @@ describe('useDataTable', () => { expect(onColumnFiltersChange).toHaveBeenCalledWith([{ id: 'role', value: 'Member' }]); }); + + it('respects controlled columnFilters prop', () => { + const controlled: Array<{ id: string; value: unknown }> = [{ id: 'role', value: 'Admin' }]; + const { result } = renderHook(() => useDataTable({ data: DATA, columnFilters: controlled })); + + act(() => result.current.setColumnFilters([])); + + expect(result.current.columnFilters).toEqual(controlled); + }); }); describe('globalFilter', () => { @@ -158,6 +176,14 @@ describe('useDataTable', () => { expect(onGlobalFilterChange).toHaveBeenCalledWith('bob'); }); + + it('respects controlled globalFilter prop', () => { + const { result } = renderHook(() => useDataTable({ data: DATA, globalFilter: 'alice' })); + + act(() => result.current.setGlobalFilter('')); + + expect(result.current.globalFilter).toBe('alice'); + }); }); describe('pagination', () => { @@ -251,6 +277,24 @@ describe('useDataTable', () => { expect(result.current.pagination.pageIndex).toBe(1); }); + it('nextPage does not advance past the last page', () => { + const { result } = renderHook(() => + useDataTable({ data: DATA, totalCount: 10, defaultPagination: { pageIndex: 0, pageSize: 10 } }), + ); + + act(() => result.current.nextPage()); + + expect(result.current.pagination.pageIndex).toBe(0); + }); + + it('previousPage does not go below page 0', () => { + const { result } = renderHook(() => useDataTable({ data: DATA })); + + act(() => result.current.previousPage()); + + expect(result.current.pagination.pageIndex).toBe(0); + }); + it('setPageSize updates pageSize and resets pageIndex to 0', () => { const { result } = renderHook(() => useDataTable({ data: DATA, defaultPagination: { pageIndex: 3, pageSize: 10 } }), @@ -276,6 +320,15 @@ describe('useDataTable', () => { expect(onPaginationChange).toHaveBeenCalledWith({ pageIndex: 1, pageSize: 10 }); }); + + it('respects controlled pagination prop', () => { + const controlled: PaginationState = { pageIndex: 2, pageSize: 10 }; + const { result } = renderHook(() => useDataTable({ data: DATA, totalCount: 50, pagination: controlled })); + + act(() => result.current.nextPage()); + + expect(result.current.pagination).toEqual(controlled); + }); }); describe('row selection', () => { @@ -340,6 +393,14 @@ describe('useDataTable', () => { expect(result.current.getIsSomeRowsSelected()).toBe(false); }); + it('getIsSomeRowsSelected() returns false when all rows are selected', () => { + const { result } = renderHook(() => + useDataTable({ data: DATA, defaultRowSelection: { '0': true, '1': true, '2': true } }), + ); + + expect(result.current.getIsSomeRowsSelected()).toBe(false); + }); + it('toggleAllRowsSelected() selects all rows', () => { const { result } = renderHook(() => useDataTable({ data: DATA })); @@ -367,5 +428,14 @@ describe('useDataTable', () => { expect(onRowSelectionChange).toHaveBeenCalledWith({ '1': true }); }); + + it('respects controlled rowSelection prop', () => { + const controlled = { '0': true }; + const { result } = renderHook(() => useDataTable({ data: DATA, rowSelection: controlled })); + + act(() => result.current.rows[0].toggleSelected()); + + expect(result.current.rowSelection).toEqual(controlled); + }); }); }); diff --git a/packages/headless/src/hooks/use-data-table.ts b/packages/headless/src/hooks/use-data-table.ts index 333bd6e36ae..ac36f745643 100644 --- a/packages/headless/src/hooks/use-data-table.ts +++ b/packages/headless/src/hooks/use-data-table.ts @@ -17,6 +17,7 @@ function functionalUpdate(updater: Updater, old: T): T { export interface UseDataTableOptions { data: TData[]; + getRowId?: (row: TData, index: number) => string; totalCount?: number; sorting?: SortingState; @@ -140,7 +141,7 @@ export function useDataTable(opts: UseDataTableOptions): UseDataTa const rows = useMemo[]>( () => opts.data.map((original, i) => { - const id = String(i); + const id = opts.getRowId ? opts.getRowId(original, i) : String(i); return { id, original, @@ -148,7 +149,7 @@ export function useDataTable(opts: UseDataTableOptions): UseDataTa toggleSelected: () => setRowSelection(old => ({ ...old, [id]: !old[id] })), }; }), - [opts.data, rowSelection, setRowSelection], + [opts.data, opts.getRowId, rowSelection, setRowSelection], ); // ── Pagination helpers ────────────────────────────────────────────────────── @@ -165,12 +166,23 @@ export function useDataTable(opts: UseDataTableOptions): UseDataTa const getCanPreviousPage = useCallback(() => pagination.pageIndex > 0, [pagination.pageIndex]); - const nextPage = useCallback(() => setPagination(p => ({ ...p, pageIndex: p.pageIndex + 1 })), [setPagination]); + const nextPage = useCallback(() => { + if (getCanNextPage()) { + setPagination(p => ({ ...p, pageIndex: p.pageIndex + 1 })); + } + }, [getCanNextPage, setPagination]); - const previousPage = useCallback(() => setPagination(p => ({ ...p, pageIndex: p.pageIndex - 1 })), [setPagination]); + const previousPage = useCallback( + () => setPagination(p => ({ ...p, pageIndex: Math.max(0, p.pageIndex - 1) })), + [setPagination], + ); const setPageSize = useCallback( - (size: number) => setPagination(() => ({ pageIndex: 0, pageSize: size })), + (size: number) => { + if (size > 0) { + setPagination(() => ({ pageIndex: 0, pageSize: size })); + } + }, [setPagination], ); @@ -181,7 +193,10 @@ export function useDataTable(opts: UseDataTableOptions): UseDataTa [rows, rowSelection], ); - const getIsSomeRowsSelected = useCallback(() => rows.some(r => !!rowSelection[r.id]), [rows, rowSelection]); + const getIsSomeRowsSelected = useCallback( + () => rows.some(r => !!rowSelection[r.id]) && !getIsAllRowsSelected(), + [rows, rowSelection, getIsAllRowsSelected], + ); const toggleAllRowsSelected = useCallback(() => { setRowSelection(getIsAllRowsSelected() ? {} : Object.fromEntries(rows.map(r => [r.id, true]))); From 97669579eb5ea192a80dabcc14e4f3ecc0365ccc Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 15 Jun 2026 16:52:49 -0400 Subject: [PATCH 3/5] fix(headless): fix lint errors in useDataTable - pass onChange callbacks directly (avoids non-null assertions, OnChangeFn is assignable to (value: T) => void via contravariance) - destructure getRowId before useMemo to satisfy react-hooks/exhaustive-deps - combine type and value imports from use-data-table to fix simple-import-sort --- .../headless/src/hooks/use-data-table.test.ts | 3 +-- packages/headless/src/hooks/use-data-table.ts | 19 ++++++++----------- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/packages/headless/src/hooks/use-data-table.test.ts b/packages/headless/src/hooks/use-data-table.test.ts index ef3b74061e9..510e82754c8 100644 --- a/packages/headless/src/hooks/use-data-table.test.ts +++ b/packages/headless/src/hooks/use-data-table.test.ts @@ -1,8 +1,7 @@ import { act, renderHook } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; -import type { PaginationState } from './use-data-table'; -import { useDataTable } from './use-data-table'; +import { type PaginationState, useDataTable } from './use-data-table'; type Person = { id: number; name: string; role: string }; diff --git a/packages/headless/src/hooks/use-data-table.ts b/packages/headless/src/hooks/use-data-table.ts index ac36f745643..65e8217b4a4 100644 --- a/packages/headless/src/hooks/use-data-table.ts +++ b/packages/headless/src/hooks/use-data-table.ts @@ -79,34 +79,30 @@ export interface UseDataTableReturn { export function useDataTable(opts: UseDataTableOptions): UseDataTableReturn { // ── State slices ──────────────────────────────────────────────────────────── - const [sorting, setSortingRaw] = useControllableState( - opts.sorting, - opts.defaultSorting ?? [], - opts.onSortingChange ? (v: SortingState) => opts.onSortingChange!(v) : undefined, - ); + const [sorting, setSortingRaw] = useControllableState(opts.sorting, opts.defaultSorting ?? [], opts.onSortingChange); const [columnFilters, setColumnFiltersRaw] = useControllableState( opts.columnFilters, opts.defaultColumnFilters ?? [], - opts.onColumnFiltersChange ? (v: ColumnFiltersState) => opts.onColumnFiltersChange!(v) : undefined, + opts.onColumnFiltersChange, ); const [globalFilter, setGlobalFilterRaw] = useControllableState( opts.globalFilter, opts.defaultGlobalFilter ?? '', - opts.onGlobalFilterChange ? (v: string) => opts.onGlobalFilterChange!(v) : undefined, + opts.onGlobalFilterChange, ); const [pagination, setPaginationRaw] = useControllableState( opts.pagination, opts.defaultPagination ?? { pageIndex: 0, pageSize: 10 }, - opts.onPaginationChange ? (v: PaginationState) => opts.onPaginationChange!(v) : undefined, + opts.onPaginationChange, ); const [rowSelection, setRowSelectionRaw] = useControllableState( opts.rowSelection, opts.defaultRowSelection ?? {}, - opts.onRowSelectionChange ? (v: RowSelectionState) => opts.onRowSelectionChange!(v) : undefined, + opts.onRowSelectionChange, ); // ── Updater-aware public setters ──────────────────────────────────────────── @@ -138,10 +134,11 @@ export function useDataTable(opts: UseDataTableOptions): UseDataTa // ── Rows ──────────────────────────────────────────────────────────────────── + const { getRowId } = opts; const rows = useMemo[]>( () => opts.data.map((original, i) => { - const id = opts.getRowId ? opts.getRowId(original, i) : String(i); + const id = getRowId ? getRowId(original, i) : String(i); return { id, original, @@ -149,7 +146,7 @@ export function useDataTable(opts: UseDataTableOptions): UseDataTa toggleSelected: () => setRowSelection(old => ({ ...old, [id]: !old[id] })), }; }), - [opts.data, opts.getRowId, rowSelection, setRowSelection], + [opts.data, getRowId, rowSelection, setRowSelection], ); // ── Pagination helpers ────────────────────────────────────────────────────── From ec4b307846da0b67e32744177a77975ae92be415 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 15 Jun 2026 16:57:24 -0400 Subject: [PATCH 4/5] docs(swingset): add useDataTable hook docs page under Hooks section --- .../swingset/src/components/DocsViewer.tsx | 4 + packages/swingset/src/lib/registry.ts | 5 + .../swingset/src/stories/use-data-table.mdx | 117 ++++++++++++++++++ .../src/stories/use-data-table.stories.tsx | 7 ++ 4 files changed, 133 insertions(+) create mode 100644 packages/swingset/src/stories/use-data-table.mdx create mode 100644 packages/swingset/src/stories/use-data-table.stories.tsx diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 95c83d0de05..4ac4445a2dc 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -41,6 +41,10 @@ const docModules: Record> = { tabs: dynamic(() => import('../stories/tabs.mdx')), tooltip: dynamic(() => import('../stories/tooltip.mdx')), }, + hooks: { + // Headless hooks — alphabetical. + 'use-data-table': dynamic(() => import('../stories/use-data-table.mdx')), + }, }; interface DocsViewerProps { diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 4888815d82e..6b334a5ccf9 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -35,6 +35,7 @@ import { meta as selectMeta } from '../stories/select.stories'; import { Default as TabsComponentDefault, meta as tabsComponentMeta } from '../stories/tabs.component.stories'; import { meta as tabsMeta } from '../stories/tabs.stories'; import { meta as tooltipMeta } from '../stories/tooltip.stories'; +import { meta as useDataTableMeta } from '../stories/use-data-table.stories'; import { toSlug } from './slug'; import type { StoryModule } from './types'; @@ -68,6 +69,8 @@ const selectModule: StoryModule = { meta: selectMeta }; const tabsModule: StoryModule = { meta: tabsMeta }; const tooltipModule: StoryModule = { meta: tooltipMeta }; +const useDataTableModule: StoryModule = { meta: useDataTableMeta }; + export const registry: StoryModule[] = [ // AIO organizationProfileModule, @@ -93,6 +96,8 @@ export const registry: StoryModule[] = [ selectModule, tabsModule, tooltipModule, + // Hooks — alphabetical within the group. + useDataTableModule, ]; /** diff --git a/packages/swingset/src/stories/use-data-table.mdx b/packages/swingset/src/stories/use-data-table.mdx new file mode 100644 index 00000000000..368f29f2077 --- /dev/null +++ b/packages/swingset/src/stories/use-data-table.mdx @@ -0,0 +1,117 @@ +# useDataTable + +State manager for data tables from `@clerk/headless`. Tracks pagination, sorting, column filters, global filter, and row selection. Each state slice supports the controlled/uncontrolled pattern. Designed for server-side data: you supply the already-fetched page of rows, and the hook tracks which page, sort, and filters to request next. + +## Usage + +```tsx +import { useDataTable } from '@clerk/headless/hooks'; + +const { + rows, + pagination, + nextPage, + previousPage, + getPageCount, + rowSelection, + toggleAllRowsSelected, +} = useDataTable({ + data: pageOfData, // current page fetched from the server + getRowId: row => row.id, + totalCount: 1000, + defaultPagination: { pageIndex: 0, pageSize: 20 }, + onPaginationChange: newPagination => { + // re-fetch with the new page/size + }, +}); +``` + +## Options + +| Prop | Type | Default | Description | +| ----------------------- | -------------------------------- | -------------------------------- | ----------------------------------------------- | +| `data` | `TData[]` | required | Current page of rows | +| `getRowId` | `(row, index) => string` | `String(index)` | Stable row identifier | +| `totalCount` | `number` | — | Total record count (used to compute page count) | +| `sorting` | `SortingState` | — | Controlled sort state | +| `defaultSorting` | `SortingState` | `[]` | Initial uncontrolled sort | +| `onSortingChange` | `OnChangeFn` | — | Called when sort changes | +| `columnFilters` | `ColumnFiltersState` | — | Controlled column filters | +| `defaultColumnFilters` | `ColumnFiltersState` | `[]` | Initial uncontrolled column filters | +| `onColumnFiltersChange` | `OnChangeFn` | — | Called when column filters change | +| `globalFilter` | `string` | — | Controlled global search string | +| `defaultGlobalFilter` | `string` | `''` | Initial uncontrolled global filter | +| `onGlobalFilterChange` | `OnChangeFn` | — | Called when global filter changes | +| `pagination` | `PaginationState` | — | Controlled pagination | +| `defaultPagination` | `PaginationState` | `{ pageIndex: 0, pageSize: 10 }` | Initial uncontrolled pagination | +| `onPaginationChange` | `OnChangeFn` | — | Called when pagination changes | +| `rowSelection` | `RowSelectionState` | — | Controlled row selection | +| `defaultRowSelection` | `RowSelectionState` | `{}` | Initial uncontrolled row selection | +| `onRowSelectionChange` | `OnChangeFn` | — | Called when selection changes | + +## Return + +### Rows + +| Value | Type | Description | +| ------ | ----------------------- | ---------------------------- | +| `rows` | `DataTableRow[]` | `data` mapped to row objects | + +Each `DataTableRow` has: + +| Field | Type | Description | +| ------------------ | --------------- | ----------------------------- | +| `id` | `string` | Stable row identifier | +| `original` | `TData` | Original data item | +| `getIsSelected()` | `() => boolean` | Whether this row is selected | +| `toggleSelected()` | `() => void` | Toggle selection for this row | + +### Pagination + +| Value | Type | Description | +| ---------------------- | ----------------------------- | ------------------------------------ | +| `pagination` | `PaginationState` | Current `{ pageIndex, pageSize }` | +| `setPagination` | `OnChangeFn` | Set pagination state | +| `nextPage()` | `() => void` | Advance one page | +| `previousPage()` | `() => void` | Go back one page | +| `setPageSize(n)` | `(size: number) => void` | Change page size and reset to page 0 | +| `getCanNextPage()` | `() => boolean` | Whether a next page exists | +| `getCanPreviousPage()` | `() => boolean` | Whether a previous page exists | +| `getPageCount()` | `() => number` | `ceil(totalCount / pageSize)` | + +### Sorting + +| Value | Type | Description | +| ------------ | -------------------------- | ---------------------------------------------------- | +| `sorting` | `SortingState` | Current sort: `Array<{ id: string; desc: boolean }>` | +| `setSorting` | `OnChangeFn` | Set sort state | + +### Filters + +| Value | Type | Description | +| ------------------ | -------------------------------- | ---------------------------- | +| `columnFilters` | `ColumnFiltersState` | Current column filters | +| `setColumnFilters` | `OnChangeFn` | Set column filters | +| `globalFilter` | `string` | Current global search string | +| `setGlobalFilter` | `OnChangeFn` | Set global filter | + +### Row Selection + +| Value | Type | Description | +| ------------------------- | ------------------------------- | ----------------------------------------- | +| `rowSelection` | `RowSelectionState` | `Record` keyed by row id | +| `setRowSelection` | `OnChangeFn` | Set selection state | +| `getIsAllRowsSelected()` | `() => boolean` | All current rows are selected | +| `getIsSomeRowsSelected()` | `() => boolean` | Some (not all) rows are selected | +| `toggleAllRowsSelected()` | `() => void` | Select all; clear if all already selected | + +## Types + +```ts +type SortingState = Array<{ id: string; desc: boolean }>; +type ColumnFiltersState = Array<{ id: string; value: unknown }>; +type PaginationState = { pageIndex: number; pageSize: number }; +type RowSelectionState = Record; +type Updater = T | ((old: T) => T); +type OnChangeFn = (updaterOrValue: Updater) => void; +``` diff --git a/packages/swingset/src/stories/use-data-table.stories.tsx b/packages/swingset/src/stories/use-data-table.stories.tsx new file mode 100644 index 00000000000..58a28b772ca --- /dev/null +++ b/packages/swingset/src/stories/use-data-table.stories.tsx @@ -0,0 +1,7 @@ +import type { StoryMeta } from '@/lib/types'; + +export const meta: StoryMeta = { + group: 'Hooks', + title: 'useDataTable', + source: 'packages/headless/src/hooks/use-data-table.ts', +}; From d3baa306baf41084d94d213eda5f996e7d974e34 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 15 Jun 2026 16:58:03 -0400 Subject: [PATCH 5/5] fix(headless): fix import sort in use-data-table --- packages/headless/src/hooks/use-data-table.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/headless/src/hooks/use-data-table.ts b/packages/headless/src/hooks/use-data-table.ts index 65e8217b4a4..74fda7f7dd5 100644 --- a/packages/headless/src/hooks/use-data-table.ts +++ b/packages/headless/src/hooks/use-data-table.ts @@ -1,6 +1,7 @@ 'use client'; import { useCallback, useMemo } from 'react'; + import { useControllableState } from './use-controllable-state'; export type Updater = T | ((old: T) => T);