Browse documentation
Developer guide7 min read

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.

Terminal
npm
npm install wts-data-table@1.1.1 @wts-data-table/react@1.0.0

Load 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.

Application entry or root layout
TSX
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.

table-data.ts
TypeScript
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.
remote-data.ts
TypeScript
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.

RemoteProjectTable.tsx
TSX
'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.

ProjectTable.tsx
TSX
'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} />
  </>;
}
Try the table before wiring up your backend

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 ↗