Hooks/useDataTable
useDataTable
Client-side search, typed filters, column sorting (nulls last) and pagination for any array of rows.
- Version
- 1.0.0
- Tier
- Hook
- Updates taken
- Minor and patch
- Files
- 1
Preview
No visual preview: this item has no markup of its own.
Install
In a project already set up on the contract, which is what registers the @ja3dan prefix. If yours isn't, set it up first.
bunx shadcn@latest add @ja3dan/use-data-table Related items
Used by
Source
What lands in your project, read from the published item rather than the working tree.
"use client";import { useMemo, useState } from "react";export type SortDirection = "asc" | "desc";export type SortValue = string | number | Date | boolean | null | undefined;export type SortState = { column: string; direction: SortDirection };export type DataTableOptions<T, F extends Record<string, unknown> = Record<string, unknown>> = { data: readonly T[]; /** Rows per page. Default 10. */ pageSize?: number; /** Text the search box matches against, case-insensitively. Omit to disable search. */ searchText?: (row: T) => string; /** * One predicate per filter. A filter whose value is `null` or `undefined` is off. * The value type comes from `initialFilters`. */ filters?: { [K in keyof F]: (row: T, value: NonNullable<F[K]>) => boolean }; initialFilters?: F; /** How each sortable column reads its value. Nulls always sort last. */ sortBy?: Record<string, (row: T) => SortValue>; initialSort?: SortState | null; /** Direction a column starts in the first time it is clicked. Default "asc". */ firstDirection?: Record<string, SortDirection>;};function compare(a: SortValue, b: SortValue): number { if (a == null && b == null) return 0; if (a == null) return 1; if (b == null) return -1; if (a instanceof Date && b instanceof Date) return a.getTime() - b.getTime(); if (typeof a === "number" && typeof b === "number") return a - b; if (typeof a === "boolean" && typeof b === "boolean") return Number(a) - Number(b); return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: "base" });}/** * Client-side search, filters, sort and pagination for a table. Changing the * search, a filter or the sort returns to page 1. For server-driven tables, keep * this state in the URL instead and use the table components on their own. */export function useDataTable<T, F extends Record<string, unknown> = Record<string, unknown>>({ data, pageSize = 10, searchText, filters, initialFilters, sortBy, initialSort = null, firstDirection,}: DataTableOptions<T, F>) { const [query, setQueryState] = useState(""); const [filterValues, setFilterValues] = useState<F>(() => initialFilters ?? ({} as F)); const [sort, setSort] = useState<SortState | null>(initialSort); const [page, setPageState] = useState(1); const filtered = useMemo(() => { const needle = query.trim().toLowerCase(); return data.filter((row) => { if (needle && searchText && !searchText(row).toLowerCase().includes(needle)) return false; if (!filters) return true; return (Object.keys(filters) as (keyof F)[]).every((key) => { const value = filterValues[key]; return value == null || filters[key](row, value as NonNullable<F[typeof key]>); }); }); }, [data, query, searchText, filters, filterValues]); const sorted = useMemo(() => { const read = sort && sortBy?.[sort.column]; if (!sort || !read) return filtered; const sign = sort.direction === "asc" ? 1 : -1; return [...filtered].sort((a, b) => { const va = read(a); const vb = read(b); // Nulls stay last in both directions. if (va == null || vb == null) return compare(va, vb); return sign * compare(va, vb); }); }, [filtered, sort, sortBy]); const pageCount = Math.max(1, Math.ceil(sorted.length / pageSize)); const currentPage = Math.min(page, pageCount); const start = (currentPage - 1) * pageSize; const rows = sorted.slice(start, start + pageSize); function setQuery(next: string) { setQueryState(next); setPageState(1); } function setFilter<K extends keyof F>(key: K, value: F[K] | null) { setFilterValues((previous) => ({ ...previous, [key]: value })); setPageState(1); } function clearFilters() { setFilterValues({} as F); setPageState(1); } function toggleSort(column: string) { setSort((previous) => previous?.column === column ? { column, direction: previous.direction === "asc" ? "desc" : "asc" } : { column, direction: firstDirection?.[column] ?? "asc" }, ); setPageState(1); } const activeFilterCount = Object.values(filterValues).filter((value) => value != null).length; return { /** Rows on the current page. */ rows, /** Rows matching search and filters, across all pages. */ total: sorted.length, /** Rows before search and filters. */ unfilteredTotal: data.length, page: currentPage, pageCount, pageSize, /** 1-based index of the first and last row shown; 0 when empty. */ from: sorted.length === 0 ? 0 : start + 1, to: start + rows.length, setPage: (next: number) => setPageState(Math.min(Math.max(1, next), pageCount)), query, setQuery, filterValues, setFilter, clearFilters, activeFilterCount, sort, toggleSort, /** The direction `column` is sorted in, or null when another column is. */ directionFor: (column: string): SortDirection | null => (sort?.column === column ? sort.direction : null), };}