@nexgrid/react
v0.2.1
Published
TableX for React and Next.js — a professional, server-driven data grid with search, sorting, selection, density, exports, and responsive card layout.
Maintainers
Readme
@nexgrid/react
A server-driven data grid for React and Next.js.
TableX renders one page of rows at a time and never holds the dataset. Every
piece of user intent — page, page size, sort, search, filters — is expressed as
a single QueryState object that you own; the grid hands you the next one
and re-renders when you hand back the matching page. That is the whole contract.
There is no local sort that quietly reorders 10 rows out of 40,000, and no
client-side filter that hides records the total still counts.
Around that core it provides the things every real admin table ends up needing:
🗂️ Column Header Grouping (Multi-Level / Stacked Headers) — Group sub-columns beneath parent categories with automatic
colSpanandrowSpancalculation.🚀 Client-Side Pagination & In-Memory Engine — Zero-config in-memory paging, sorting, search, filtering, and export over local arrays.
💾 Grid State Persistence (
storageKey) — Automatically saves column widths, column order, hidden columns, and row density tolocalStorage.↔️ Column Resizing & Auto-Fit — Interactive drag resize handles and double-click auto-fit measuring.
🏷️ Active Filter Pills Bar — Interactive chip badges beneath the toolbar for active search & column filters with one-click
✕removal and "Clear all".🔍 Debounced global search (350 ms), 3-state sorting cycle (
asc → desc → cleared), multi-column sorting.📌 Pinned columns (left / right freeze), master-detail accordion row expansion, summary/aggregation footer row.
📊 Formatted Excel (
.xls) and CSV export, including whole-dataset export across pages.📱 Responsive layout — Full desktop table at ≥ 768 px, smart card list below.
💛 Dual Language Support — 100% compatible with both TypeScript (TSX) and plain JavaScript (JSX) with JSDoc typing.
Zero runtime dependencies beyond
@nexgrid/core. React is a peer dependency.Ships ESM and CJS, with a
"use client"banner so it drops straight into the Next.js App Router.
Installation
npm install @nexgrid/react @nexgrid/coreImport the stylesheet once, anywhere in your app:
import "@nexgrid/react/styles.css";Quick start
The grid is fully controlled. Hold a QueryState in state, fetch whenever it
changes, and pass the result straight through.
"use client";
import { useCallback, useEffect, useState } from "react";
import {
TableX,
defaultQuery,
serializeQuery,
type TableXReactColumn,
type PagedResponse,
type QueryState,
} from "@nexgrid/react";
import "@nexgrid/react/styles.css";
interface Student {
id: number;
name: string;
email: string;
status: "Active" | "Pending" | "Disabled";
joinedAt: string;
}
const columns: TableXReactColumn<Student>[] = [
{ accessorKey: "name", header: "Name", meta: { minWidth: 180 } },
{ accessorKey: "email", header: "Email" },
{
accessorKey: "status",
header: "Status",
meta: { align: "center", width: 130 },
cell: ({ getValue }) => {
const status = String(getValue());
return <span className={`pill pill--${status.toLowerCase()}`}>{status}</span>;
},
},
{
accessorKey: "joinedAt",
header: "Joined",
cell: ({ getValue }) => new Date(String(getValue())).toLocaleDateString(),
},
];
export function StudentsGrid() {
const [query, setQuery] = useState<QueryState>(defaultQuery());
const [page, setPage] = useState<PagedResponse<Student> | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(false);
const load = useCallback(async (next: QueryState) => {
setIsLoading(true);
setError(false);
try {
// serializeQuery produces ?page=2&pageSize=25&sort=name:asc&q=smith
const response = await fetch(`/api/students?${serializeQuery(next)}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
setPage((await response.json()) as PagedResponse<Student>);
} catch {
setError(true);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
void load(query);
}, [load, query]);
return (
<TableX
caption="Students"
columns={columns}
data={page?.items ?? []}
total={page?.total ?? 0}
query={query}
onQueryChange={setQuery}
isLoading={isLoading}
error={error}
onRetry={() => void load(query)}
enableSelection
onSelectionChange={(ids) => console.log("selected", ids)}
// Lets an export page in the whole filtered dataset, not just this page.
fetchEndpoint="/api/students"
// The grid never renders toasts — forward these to your own.
onNotify={({ type, message }) => console.info(type, message)}
/>
);
}Your endpoint must answer with a PagedResponse<T>:
{ "items": [], "page": 1, "pageSize": 10, "total": 0, "totalPages": 1 }If your API is ASP.NET Core, TableX.AspNetCore binds exactly the query string
serializeQuery produces and returns exactly this shape.
Putting the query in the URL
QueryState round-trips through a query string, so making the grid shareable and
back-button-friendly is a swap of the state hook:
const searchParams = useSearchParams();
const router = useRouter();
const query = useMemo(() => parseQuery(searchParams.toString()), [searchParams]);
<TableX
query={query}
onQueryChange={(next) => router.replace(`?${serializeQuery(next)}`)}
{...rest}
/>;parseQuery degrades safely — a bad page becomes 1, an unknown page size
becomes the default, malformed sort tokens are dropped — so a hand-edited URL can
never put the grid into an impossible state.
Next.js App Router
The published bundle starts with "use client", so <TableX /> can be imported
directly from a Server Component without a wrapper:
// app/students/page.tsx — a Server Component
import { StudentsGrid } from "./students-grid";
export default async function Page() {
return <StudentsGrid />;
}Two notes:
- Anything you pass through props still crosses the server/client boundary, so
columns(which containscellfunctions) must be defined in a file marked"use client", not in the server page. - Import
@nexgrid/react/styles.cssfrom your root layout, or from the client component itself if your setup supports component-level CSS imports.
Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| columns | TableXReactColumn<TData>[] | required | Column definitions, in display order (supports multi-level columns). |
| data | TData[] | required | The current page of rows, or the entire array if clientSidePagination is true. |
| total | number | optional in client mode | Total filtered row count from the server. Drives the pager. |
| query | QueryState | optional in client mode | The query the data above answers. |
| onQueryChange | (next: QueryState) => void | optional in client mode | Called with the next query on page / size / sort / search changes. |
| clientSidePagination | boolean | false | Enables zero-config in-memory paging, sorting, search, and filtering over local data. |
| storageKey | string | — | Persists custom column widths, column order, hidden columns, and density to localStorage. |
| caption | string | required | Accessible name for the table; also default export filename and sheet title. |
| density | "compact" \| "default" \| "comfortable" | "default" | Initial density preset. |
| enableColumnResize | boolean | true | Enables interactive drag-to-resize and double-click auto-fit on column borders. |
| enableColumnFilters | boolean | true | Enables 3-dot column filter popovers (⋮) for text, select, and range filters. |
| enableSorting | boolean | true | Global switch for column sorting. |
| enableSummaryRow | boolean | false | Renders a summary / aggregation row in <tfoot>. |
| enableRowExpansion | boolean | false | Master-detail accordion expandable sub-rows. |
| renderExpandedRow | (row: TData) => ReactNode | — | Render function for expanded row content. |
| enableBulkActions | boolean | false | Floating bottom pill bar for batch operations on selected rows. |
| isLoading | boolean | false | Replaces rows with a spinner while keeping toolbar and footer interactive. |
| error | boolean | false | Replaces the whole grid with an accessible error card. |
| onRetry | () => void | — | When set, the error card displays a retry button. |
| enableSelection | boolean | false | Renders row selection checkboxes. |
| selectionMode | "multi" \| "single" | "multi" | Whether multiple rows or only one row can be selected. |
| onSelectionChange | (ids: string[]) => void | — | Fires after each selection change. |
| enableSearch | boolean | true | Shows the debounced global search box. |
| searchPlaceholder | string | locale.searchPlaceholder | Placeholder text for the search box. |
| toolbarActions | ReactNode | — | Custom actions rendered at the end of the toolbar. |
| onRowClick | (row: TData) => void | — | Row / card click handler. Adds pointer cursor and keyboard activation. |
| getRowId | (row: TData) => string | String(row.id ?? row) | Stable row identity, used for selection and React keys. |
| showSerialNumber | boolean | true | Shows automatic S.No. column, numbered across pages. |
| enableExport | boolean | true | Shows the export menu (Excel, CSV, Clipboard). |
| exportFileName | string | caption, slug-safe | File name prefix without extension. |
| fetchEndpoint | string | — | Endpoint used to page in the full dataset during export. |
| badgeRules | readonly ExcelBadgeRule[] | core's DEFAULT_BADGE_RULES | Value-based cell styling for Excel export. |
| locale | Partial<TableXLocale> | English defaults | Overrides for any user-facing string. |
| onNotify | (notice: TableXNotice) => void | no-op | Receives { type, message } for notifications. |
| theme | "light" \| "dark" \| "auto" | "light" | Sets .tbx-dark / .tbx-auto theme mode. |
Column definitions
A column is a plain object, structurally compatible with TanStack Table's
ColumnDef — existing column sets usually work unchanged.
| Field | Type | Description |
|-------|------|-------------|
| id | string | Column id. Falls back to accessorKey. |
| accessorKey | string | The row property this column reads. |
| header | string \| (ctx) => ReactNode | Header content. A string is also used for menus and export headers. |
| columns | TableXReactColumn<TData>[] | Nested sub-columns for multi-level stacked column header groups. |
| cell | (ctx: { row: { original: TData }, getValue(): unknown }) => ReactNode | Custom cell renderer. Without it the raw value is rendered as text. |
| enableSorting | boolean | Sorting is on by default; set false to opt out. |
| meta | TableXColumnMeta | Layout and behavior hints — see below. |
meta
| Field | Type | Description |
|-------|------|-------------|
| width | number | Fixed pixel width. |
| minWidth | number | Minimum width in pixels. Defaults to 120 when no width is set. |
| align | "left" \| "center" \| "right" | Header and cell alignment. |
| hidden | boolean | Start hidden. Still listed in the Columns menu. |
| hideable | boolean | Set false to keep the column out of the Columns menu. |
| exportable | boolean | Set false to keep the column out of CSV/Excel exports. |
| serverFilterable, serverFilterField, filterOptions | — | Declare a column as server-filterable (filter[field]=value). |
Two ids are structural: select and actions. They are never sortable, never
hideable, and never exported.
Custom cells
cell returns any ReactNode, and the same renderer is used by the table and
the mobile card list, so the two can never drift apart.
const columns: TableXReactColumn<Student>[] = [
// A status pill.
{
accessorKey: "status",
header: "Status",
meta: { align: "center", width: 130 },
cell: ({ getValue }) => {
const status = String(getValue());
return <span className={`pill pill--${status.toLowerCase()}`}>{status}</span>;
},
},
// Composed from more than one field — reach through `row.original`.
{
id: "student",
header: "Student",
cell: ({ row }) => (
<div className="stack">
<strong>{row.original.name}</strong>
<small>{row.original.email}</small>
</div>
),
},
// A row action menu. `actions` is structural: unsortable and never exported.
{
id: "actions",
header: "",
meta: { align: "right", width: 64 },
cell: ({ row }) => (
<button type="button" onClick={(event) => event.stopPropagation()}>
Edit
</button>
),
},
];Two things worth knowing:
- Exports read the underlying row value, not the rendered cell. A custom cell
is presentation; the
statuscolumn above exportsActive, not the markup of the pill. Setmeta.exportable: falseon columns that carry no data. - When
onRowClickis set, callevent.stopPropagation()in interactive cell content so a button click does not also open the row. Selection checkboxes already do this for you.
Theming
Every color and shape in the stylesheet reads a CSS custom property, so you
re-skin the grid by overriding tokens — no class overrides, no !important.
.tbx-root {
--tbx-primary: #7c3aed;
--tbx-primary-fg: #ffffff;
--tbx-radius: 8px;
--tbx-font: "Inter", system-ui, sans-serif;
}| Token | Purpose |
|-------|---------|
| --tbx-font, --tbx-font-mono | Body font, and the serial-number font. |
| --tbx-bg | Input and pager background. |
| --tbx-card, --tbx-card-2 | Panel background, and the table header band. |
| --tbx-border | Every border and divider. |
| --tbx-fg, --tbx-muted-fg | Primary and secondary text. |
| --tbx-muted | Hover fills and subtle chips. |
| --tbx-primary, --tbx-primary-fg | Accent: sort icons, current page, selection. |
| --tbx-danger | Destructive accents. |
| --tbx-radius, --tbx-radius-sm | Panel and control corner radii. |
| --tbx-shadow, --tbx-focus-ring | Elevation, and the focus ring. |
Dark mode is a class, not a media query, so it can follow whatever your app already uses:
<TableX theme="dark" {...props} /> {/* always dark */}
<TableX theme="auto" {...props} /> {/* follows the OS */}theme="dark" puts .tbx-dark on the grid root. If your app already toggles a
dark class higher up the tree, add tbx-dark alongside it and leave theme
alone — the stylesheet matches .tbx-dark .tbx-root as well.
Responsive behavior is driven entirely by the stylesheet: the grid renders both a table and a card list, and CSS shows the table at ≥ 768px and the cards below it.
Exporting
The export menu offers a formatted Excel workbook (.xls, with colored status
badges) and a raw CSV (RFC 4180, UTF-8 BOM, with spreadsheet-formula injection
neutralized). Both write the visible columns, minus anything marked
meta.exportable: false.
By default an export contains the current page. Pass fetchEndpoint and the grid
will page through the rest of the filtered dataset first, preserving the active
search, sort, and filters, at 100 rows per request up to a 2,000-row safety cap.
If those requests fail it notifies you and falls back to the current page rather
than producing nothing.
<TableX
fetchEndpoint="/api/students"
exportFileName="student_roster"
badgeRules={[
{ values: ["Active"], background: "#dcfce7", color: "#15803d" },
{ values: ["Disabled"], background: "#fee2e2", color: "#b91c1c" },
]}
onNotify={({ type, message }) => toast[type](message)}
{...props}
/>To export server-side instead — a real .xlsx, a background job, a signed
download URL — pass onExportAll. It replaces the built-in flow completely.
Notifications
The grid never renders toasts. A toast belongs to your design system, and two
competing toast stacks in one page is a worse bug than no toast at all. Anything
the grid wants to say arrives at onNotify as { type, message } where type
is "info" | "success" | "error", ready to forward to whatever you already use.
Localization
Every user-facing string comes from a locale object. Override any subset:
<TableX
locale={{
searchPlaceholder: "Rechercher…",
emptyText: "Aucun enregistrement ne correspond à votre recherche.",
showingRange: "Affichage de {start} à {end} sur {total} entrées",
rowsPerPage: "Lignes :",
}}
{...props}
/>Templates keep their {placeholder} tokens so word order stays yours. Import
DEFAULT_LOCALE from this package to see every key.
Accessibility
aria-label={caption}on the table; every icon-only control has an accessible name drawn from the locale.- Sortable headers are focusable, activate on Enter or Space, and carry
aria-sort(ascending/descending/none). - Menus are
role="menu"withrole="menuitemcheckbox"andaria-checkeditems; they close on outside click and on Escape, which returns focus to the trigger. - The header checkbox reports the mixed state when only part of a page is selected.
- The current page button carries
aria-current="page". - When
onRowClickis set, rows and cards become focusable and activate on Enter. Keep genuinely interactive content in a cell rather than relying on the row handler alone.
Re-exported from @nexgrid/core
For convenience, the pieces a host needs to drive a controlled grid are
re-exported from this package, so most apps never import @nexgrid/core
directly:
defaultQuery, parseQuery, serializeQuery, buildQueryUrl, primarySort,
withToggledSort, withSort, withSearch, withPage, withPageSize,
withFilter, totalPagesFor, isPageSize, PAGE_SIZES, DEFAULT_PAGE_SIZE,
DEFAULT_LOCALE, resolveLocale.
Always mutate a QueryState through those reducers rather than spreading it by
hand — they are what guarantee that a search or a page-size change resets to page
one and that the sort cycle stays asc → desc → cleared across every adapter.
Author & Maintainer
Chhagan Sinha
- 📧 Contact: [email protected]
- 🐙 GitHub: @ChhaganSinha
License
MIT © 2026 Chhagan Sinha
