React data table guide
Use the official React wrapper without giving up React-owned state. Build an editable project table with memoized options, then connect a server query with cleanup that also works during Strict Mode remounts.
Install in a React application
#Use an existing React 18 or 19 TypeScript application. React and React DOM remain peer dependencies; they are not bundled into the wrapper. This guide is checked with wts-data-table 1.1.1 and @wts-data-table/react 1.0.0.
npm install wts-data-table@1.1.1 @wts-data-table/react@1.0.0Load styles and mount the component
#Import the CSS once in your application entry (for example src/main.tsx). In Next.js App Router, import it from app/layout.tsx and render ProjectTable from a page. Keep the use client directive on components that use hooks and the wrapper; do not access window at module scope.
import 'wts-data-table/styles.css';Define typed rows and editable columns
#Create table-data.ts beside your component. Stable project IDs preserve selection when rows change. Name and budget are editable; status supplies a select filter. The examples below use these three local rows and need no API key.
import type { DataTableOptions } from 'wts-data-table';
export interface Project {
id: string;
name: string;
status: string;
budget: number;
}
export const projects: Project[] = [
{ id: 'p1', name: 'Website redesign', status: 'In progress', budget: 24000 },
{ id: 'p2', name: 'Customer portal', status: 'Planning', budget: 18000 },
{ id: 'p3', name: 'Analytics dashboard', status: 'Complete', budget: 32000 }
];
export const columns: DataTableOptions<Project>['columns'] = [
{ accessor: 'name', header: 'Project', editable: true },
{ accessor: 'status', header: 'Status', filterVariant: 'select' },
{
accessor: 'budget', header: 'Budget', dataType: 'number',
editable: {
type: 'number', min: 0,
validate: value => Number.isFinite(Number(value)) && Number(value) >= 0
? undefined : 'Enter a non-negative budget.'
}
}
];Filter, select, and edit
#The complete component below enables global search, per-column filters, multiple row selection, and cell editing. Double-click a name or budget cell, or focus it and press Enter. Enter commits; Escape cancels. Negative budgets fail validation. These examples save edits in local application state only; a reload restores the sample data.
- showGlobalFilter and columnFilters control the search UI. Use controller.setGlobalFilter() or setColumnFilter() for your own controls.
- selectionMode enables checkboxes. onStateChange receives rowSelection IDs; the selected count here describes explicit selection in this local dataset, not an all-filtered server selection.
- For durable edits, await your authenticated API in onEditCommit before updating framework data. Reject or throw on failure so the table rolls back. Handle onEditError to display a useful message; validate and authorize again on the server.
Connect server-side data
#This is an optional integration recipe, not a hosted API. Create remote-data.ts and implement POST /api/projects/query on your backend. The request contains { state, filtering }; return { rows, rowCount }, where rowCount is the total after filtering, not the current page length. state.pagination.pageIndex is zero-based. The transport validates the response and forwards AbortSignal for cancellation.
- Process filtering, sorting, and pagination on the server when all three manual flags are true. The driver debounces queries, cancels superseded requests, caches results, and ignores stale responses.
- The following remote component is read-only. The data-source driver owns loaded rows; do not replace its stable empty input with the local sample array during rerenders.
- Require authentication and authorization, allowlist column IDs and operators, cap page size, and apply your application’s CSRF protection. Never interpolate untrusted field names into SQL.
- Loading and error phases appear in the status label. Refresh / retry forces a new request. Destroy the driver on unmount to abort requests and release subscriptions.
import type { DataTable } from 'wts-data-table';
import {
createDataTableDataSourceController,
type DataTableDataSourceResult,
type DataTableDataSourceStatus
} from 'wts-data-table/data-source';
import type { Project } from './table-data';
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function isProjectPage(value: unknown): value is DataTableDataSourceResult<Project> {
return isRecord(value) && Array.isArray(value.rows)
&& typeof value.rowCount === 'number' && Number.isSafeInteger(value.rowCount)
&& value.rowCount >= value.rows.length
&& value.rows.every(row => isRecord(row)
&& typeof row.id === 'string' && typeof row.name === 'string'
&& typeof row.status === 'string' && typeof row.budget === 'number'
&& Number.isFinite(row.budget));
}
export function connectProjects(
table: DataTable<Project>,
onStatusChange: (status: DataTableDataSourceStatus) => void
) {
return createDataTableDataSourceController<Project>({
table: table.core,
debounceMs: 200,
onStatusChange,
async dataSource({ state, filtering, signal }) {
const response = await fetch('/api/projects/query', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ state, filtering }),
signal
});
if (!response.ok) throw new Error('Project request failed: ' + response.status);
const page: unknown = await response.json();
if (!isProjectPage(page)) throw new Error('Invalid project response');
return page;
}
});
}Mount the remote React table
#Render RemoteProjectTable instead of the local component once your endpoint is implemented. Module-level options and initialRows stay stable. onReady connects the driver and onDestroy releases it during unmount or the development Strict Mode setup/cleanup cycle.
'use client';
import { useCallback, useRef, useState } from 'react';
import type { DataTable } from 'wts-data-table';
import { WtsDataTableReact, type WtsReactDataTableOptions } from '@wts-data-table/react';
import { columns, type Project } from './table-data';
import { connectProjects } from './remote-data';
const initialRows: readonly Project[] = [];
const options: WtsReactDataTableOptions<Project> = {
columns, getRowId: row => row.id, ariaLabel: 'Remote projects',
showGlobalFilter: true, columnFilters: 'always', editing: false,
manualFiltering: true, manualSorting: true, manualPagination: true,
initialState: { pagination: { pageSize: 25 } }
};
export function RemoteProjectTable() {
const [status, setStatus] = useState('idle');
const remote = useRef<ReturnType<typeof connectProjects> | undefined>(undefined);
const disconnect = useCallback(() => {
remote.current?.destroy();
remote.current = undefined;
}, []);
const connect = useCallback((table: DataTable<Project>) => {
disconnect();
remote.current = connectProjects(table, ({ phase }) => setStatus(phase));
}, [disconnect]);
return <>
<p role="status">{status}</p>
<button type="button" onClick={() => { void remote.current?.refresh(true); }}>
Refresh / retry
</button>
<WtsDataTableReact data={initialRows} options={options}
onReady={connect} onDestroy={disconnect} />
</>;
}React lifecycle and controlled state
#Keep options and renderers referentially stable with useMemo or module-level constants. A fresh options object reconstructs the controller; changing only data calls setData on the existing table. Avoid using a changing table key as an update mechanism.
- Use a stable useRef<DataTable<Project> | null>(null) when you need the forwarded controller. The wrapper clears it on unmount.
- For controlled state, pass state={state} and onStateChange={setState}, starting with useState<DataTableState>(). Do not keep replacing state with an old saved snapshot.
- Never call hooks at module scope. Keep callback dependencies accurate; use functional state updates or refs when a memoized callback needs current application data.
- React-owned renderers can return JSX for cells, filters, headers, and menus. Memoize the renderers object too, and let the wrapper release its renderer roots.
Troubleshoot React and Next.js
#Separate a wrapper lifecycle issue from an API or application-state issue.
- Table remounts on every render: move inline options and renderers into useMemo or stable module constants.
- Invalid hook call: check that hooks are inside a component and that the application has one compatible React runtime.
- Next.js hooks/server-component error: put use client at the top of the table component, not inside a function.
- Duplicate requests in development: Strict Mode intentionally exercises cleanup. Keep onDestroy cleanup and AbortSignal forwarding; do not disable Strict Mode to hide leaked subscriptions.
- Edits disappear after an unrelated render: persist committed data in React state, as the local example does. Server edits must also be saved to your backend.
ProjectTable.tsx
#Complete local component. Add table-data.ts from the typed-rows section, import the stylesheet in your application entry, then render <ProjectTable />. The use client directive is needed for Next.js App Router and is harmless in a client-only build.
'use client';
import { useMemo, useState } from 'react';
import {
WtsDataTableReact,
type WtsReactDataTableOptions
} from '@wts-data-table/react';
import { columns, projects, type Project } from './table-data';
export function ProjectTable() {
const [rows, setRows] = useState<readonly Project[]>(projects);
const [selected, setSelected] = useState(0);
const options = useMemo<WtsReactDataTableOptions<Project>>(() => ({
columns,
getRowId: row => row.id,
ariaLabel: 'Project delivery',
showGlobalFilter: true,
columnFilters: 'always',
selectionMode: 'multiple',
editing: true,
initialState: { pagination: { pageSize: 10 } },
onStateChange: state => setSelected(state.rowSelection.length),
// Local-only edits: keep React data in sync without replacing options.
onEditCommit: ({ instance }) => setRows([...instance.getData()])
}), []);
return <>
<p aria-live="polite">{selected} selected</p>
<WtsDataTableReact data={rows} options={options} />
</>;
}Explore search, selection, editing, and runtime options in the interactive example.
Open the configurable example →Ready to try it with real data?
Explore configurable examples ↗