Universal Data Table — required implementation
This is not an example. It is the required assembly. Every rule below exists because a production Anshin application got it wrong, and not one of the defects threw an error.
Why this page exists
Four applications assembled the UDT from primitives and four got it wrong, in four different ways. So did this demo, on its first pass. The common factor is that the correct assembly required knowing something the types did not enforce — which makes it a library problem, not four app problems. Until the types enforce it, copy the implementation below rather than assembling your own.
Running, against live data
Then search zzzz to see the empty state — the two must not look alike, which is RULE 2. Open any row with the eye icon for the record surface.
Tags | Actions | |||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Memo Standard v1 | Memo | Archived | Iris Sato | Clinical Operations | 1.4 MB | 3.27 | clinical | Yes | 8 | 2026-09-19 | ||
| Contract Procedure v2 | Contract | Archived | Sanjay Jansen | Revenue Cycle | 567.3 KB | 4.62 | training | No | 4 | 2026-08-12 | ||
| Report Review v2 | Report | Approved | Tomas Eriksen | Engineering | 2.5 MB | — | vendor | No | 7 | 2026-07-30 | ||
| Policy Procedure v1 | Policy | Approved | Rosa Jansen | Information Security | 4.1 MB | 1.78 | clinical | No | 8 | 2026-08-19 | ||
| SOP Review v4 | SOP | Draft | Uma Eriksen | Engineering | 2.4 MB | 4.07 | audit vendor +1 | Yes | 2 | 2026-08-16 | ||
| Memo Policy v3 | Memo | In Review | Kofi Diallo | Revenue Cycle | 3.6 MB | — | training | No | 5 | 2026-09-30 | ||
| Report Standard v5 | Report | In Review | Kofi Jansen | Engineering | 4.5 MB | 4.17 | incident | No | 8 | 2026-10-01 | ||
| SOP Procedure v4 | SOP | Archived | Iris Sato | Compliance | 2.6 MB | 4.16 | hipaa onboarding | Yes | 10 | 2026-07-25 | ||
| Policy Policy v4 | Policy | Approved | Kofi Jansen | Clinical Operations | 3.2 MB | — | clinical training | Yes | 7 | 2026-07-31 | ||
| SOP Review v2 | SOP | In Review | Dana Okafor | Compliance | 2.9 MB | 4.07 | training | Yes | 10 | 2026-08-25 | ||
| Policy Policy v5 | Policy | Approved | Uma Eriksen | Engineering | 4.1 MB | 4.61 | hipaa | Yes | 2 | 2026-07-13 | ||
| Memo Procedure v4 | Memo | Archived | Sanjay Jansen | Compliance | 1.6 MB | 2.36 | clinical | No | 5 | 2026-07-28 | ||
| Memo Guideline v4 | Memo | In Review | Dana Okafor | Revenue Cycle | 2.1 MB | — | audit incident | No | 6 | 2026-07-12 | ||
| Memo Guideline v3 | Memo | Approved | Kofi Diallo | Information Security | 1.1 MB | 1.37 | clinical | No | 6 | 2026-07-06 | ||
| Contract Standard v5 | Contract | Draft | Dana Sato | Human Resources | 4.1 MB | 1.72 | audit | No | 4 | 2026-08-07 |
Layout persists under anshin:anshin-ui-sdk-demo:udt-standard:v1. Row selection, search text and page index are deliberately not stored — a filter value is user-entered and may be a patient name or MRN.
The eleven rules, and who proved each one
| # | Rule | What happens if you skip it | Proved by |
|---|---|---|---|
| 1 | Set globalFilterFn: 'includesString' explicitly | TanStack's 'auto' default declines to filter non-string columns. The search box accepts input and changes nothing. No error. | this demo (first pass); ETL’s predecessor wrapper |
| 2 | Render an error state distinct from the empty state | “We could not reach the server” renders as “no results”. An operator is told there is no data when we failed to ask. | anshin-etl (8 pages), anshin-automate-sdk (2 pages), anshin-wiki-sdk |
| 3 | Do not pass enableFieldSearch or actionBar | Both are declared in the props type and appear zero times in the compiled implementation. They typecheck and are unreachable. | OPS-CLIF (reported); OPS-Automate-SDK (confirmed statically) |
| 4 | Refuse export formats you cannot produce — never substitute | A bridge that ignores the format writes CSV for every choice. “Pipe-delimited” yields a comma-delimited .csv with no error. | OPS-Automate-SDK (found it in their own code, unreported) |
| 5 | columnResizeMode: 'onChange' | The default 'onEnd' applies width only on pointer-up, which reads as a broken drag handle. | Bryan, against 5.2.0 |
| 6 | enableMultiSort AND isMultiSortEvent — both | Either alone does nothing. toggleSorting also needs its second argument to append rather than replace. | Bryan, reported multi-sort “not working” |
| 7 | Persist layout only, under a versioned key | Persisting filter values puts user-entered data — possibly a patient name or MRN — into localStorage. Version mismatch must discard, not migrate. | persistence contract, 5.2.0 |
| 8 | Pin via deriveInitialPinning / reconcilePinning | meta.pinned is inert alone. Pinning any column right shunts the Actions column to the left. | Bryan, against 5.2.1 |
| 9 | Give the table a bounded height | The page grows without limit and the toolbar scrolls out of reach. | Bryan, against 5.2.1 |
| 10 | Expose the sort state when multi-sort is on | Sort precedence is invisible and users cannot tell why rows are ordered as they are. | Bryan, against 5.2.1 |
| 11 | Select column first, Actions column last | The pinning helpers assume that order; interleaving makes Actions jump column. | pinning helpers, 5.3.0 |
The implementation — components/udt-canonical.tsx
Read from disk at request time, so this is verifiably the code running above.
'use client';
/**
* ============================================================================
* THE CANONICAL UDT. COPY THIS FILE. DO NOT ASSEMBLE YOUR OWN.
* ============================================================================
*
* Every numbered rule below exists because a real Anshin application got it
* wrong in production. None of them is a style preference. Each one names the
* app that hit it, because "someone might" is ignorable and "ETL shipped this
* to eight screens" is not.
*
* If you are reading this because your table "mostly works" — that is exactly
* the state every one of these defects produced. Not one of them threw.
*/
import * as React from 'react';
import {
DataTableSortable,
DataTableToolbar,
DataTableColumnHeader,
DataTableSortPanel,
createSelectColumn,
createActionsColumn,
exportData,
downloadBlob,
formatFileSize,
useTablePreferences,
buildFilterPredicate,
anshinColumnFilterFn,
deriveInitialPinning,
reconcilePinning,
columnIdsOf,
preferenceKey,
type TableDensity,
// ExportFormatType is no longer imported: it is now an alias of ExportFormat, so importing both
// implied a distinction that no longer exists and left a dead symbol behind.
type ExportFormat,
type FilterConditionGroup,
} from '@anshinus/data-table';
import { Badge, Button } from '@anshinus/ui';
import {
useReactTable,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
type ColumnDef,
type SortingState,
type VisibilityState,
type ColumnSizingState,
type ColumnPinningState,
} from '@tanstack/react-table';
/* ---------------------------------------------------------------------------
* RULE 4 — EXPORT: REFUSE WHAT YOU CANNOT PRODUCE. NEVER SUBSTITUTE.
*
* WHO GOT THIS WRONG: OPS-Automate-SDK, 2026-09-24. Their bridge took
* ExportFormat and ignored it, always writing CSV. A user choosing
* "pipe-delimited" received a comma-delimited file named .csv, with no error.
* They had not reported it because they had not registered it AS a workaround.
*
* ── THE WORKAROUND THIS FILE CARRIED IS NOW OBSOLETE (5.3.12 upgrade) ──────
* There used to be TWO incompatible unions here, and this file bridged them:
* ExportFormat csv excel json pdf txt tsv pipe xml
* ExportFormatType csv tsv json xml excel-xml pdf
* `excel` != `excel-xml`, and txt/pipe had no target at all, so bridging by
* name silently yielded nothing for three of eight formats.
*
* @anshinus/data-table collapsed them: `ExportFormat` is now an ALIAS of
* `ExportFormatType` (one union, every member implemented), and `pipe`/`txt`
* are real. So the bridge is an identity function over a single type, and
* keeping it is strictly worse than deleting it — its `switch` does not list
* `excel-xml` or `print-html`, so after the upgrade it fell through and
* returned `undefined`, which TypeScript caught as a missing return.
*
* THE DEMO FINDING THIS IS THE POINT. A consumer that pinned 5.3.1 and carried
* this helper got a silent `undefined` -> "cannot produce it" alert for two
* formats that now work. Deleting the workaround is the upgrade.
*
* `exportFilename()` from the package is the authority on the extension --
* notably `pdf`/`print-html` produce .html, which is why nothing here maps
* formats to names by hand any more.
* ------------------------------------------------------------------------- */
export interface CanonicalTableProps<T extends { id: number | string }> {
rows: T[];
columns: ColumnDef<T, unknown>[];
/** Stable surface name. Feeds the versioned persistence key — see RULE 7. */
surface: string;
/**
* RULE 2 — THE ERROR PATH IS NOT OPTIONAL.
*
* Note the type: `Error | null`, REQUIRED and nullable — deliberately not
* `error?: Error`. An optional key lets a call site omit it silently, and
* THE OMISSION IS THE DEFECT. Required-nullable forces every call site to
* answer the question.
*
* This correction is OPS-Wiki-SDK's. Making it required made them visit all
* five of their call sites, which surfaced something they would otherwise
* have missed: three of their tables are server-rendered, so a database
* failure throws during the RSC render before the component ever mounts.
* There, `error={null}` is correct ONLY IF the failure surfaces somewhere
* else — a route-level error boundary. Otherwise the framework renders a
* generic page and the user still learns nothing: the same silent wrong
* answer, one layer up.
*
* So every `error={null}` should carry a comment saying where the failure
* surfaces instead. A null with a reason is a decision; a null without one
* is the bug wearing a fix.
*/
error: Error | null;
onRetry?: () => void;
}
export function CanonicalDataTable<T extends { id: number | string }>({
rows,
columns,
surface,
error,
onRetry,
}: CanonicalTableProps<T>) {
const [sorting, setSorting] = React.useState<SortingState>([]);
const [globalFilter, setGlobalFilter] = React.useState('');
const [rowSelection, setRowSelection] = React.useState({});
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({});
const [columnOrder, setColumnOrder] = React.useState<string[]>([]);
const [columnSizing, setColumnSizing] = React.useState<ColumnSizingState>({});
const [density, setDensity] = React.useState<TableDensity>('default');
/*
RULE 12 — ADVANCED FILTERS ARE APPLIED WITH THE SDK'S OWN EVALUATOR.
The toolbar's filter icon opens the built-in Universal Filter Builder and
hands back condition groups. Evaluate them with `buildFilterPredicate` —
never by hand. It covers 16 operators and negated groups, and it is the
function the UFB test suite exercises; a second interpretation of "does not
contain" written in an app is a second set of bugs.
Filtering the ROW ARRAY rather than setting column filters keeps grouped
OR/AND logic intact, which per-column filters cannot express.
Note the filter VALUES are deliberately not persisted — see RULE 7. A filter
value is user-entered and may be a patient name.
*/
const [filterGroups, setFilterGroups] = React.useState<FilterConditionGroup[]>([]);
const visibleRows = React.useMemo(() => {
const predicate = buildFilterPredicate<T>(filterGroups);
return predicate ? rows.filter(predicate) : rows;
}, [rows, filterGroups]);
/* -------------------------------------------------------------------------
* RULE 8 — PIN THROUGH THE HELPERS, NOT BY HAND.
*
* `meta.pinned` on a column definition is INERT on its own. The Actions
* column must be reconciled against the columns that actually exist, or
* pinning one column right shunts Actions to the left — reported by Bryan
* directly against 5.2.1.
* ---------------------------------------------------------------------- */
const known = React.useMemo(() => columnIdsOf(columns), [columns]);
const [columnPinning, setColumnPinning] = React.useState<ColumnPinningState>(() =>
deriveInitialPinning(columns),
);
/* -------------------------------------------------------------------------
* RULE 7 — PERSISTENCE IS VERSIONED, AND NEVER PERSISTS PHI.
*
* Key shape: anshin:{app}:{surface}:v{N}. A version mismatch DISCARDS rather
* than migrates — a half-understood old shape is worse than a default.
*
* Row selection, filter VALUES and page index are deliberately never stored.
* A filter value is user-entered data and may be a patient name or MRN;
* localStorage is not a lawful place for it. Layout is safe, content is not.
* ---------------------------------------------------------------------- */
const { prefs, setPrefs, key: prefsKey } = useTablePreferences({
app: 'anshin-ui-sdk-demo',
surface,
version: 1,
storage: 'local',
});
// Adopt stored layout once, on mount. Reconcile pinning against the columns
// that actually exist — a stored pin for a deleted column is dropped, not
// trusted. Read from `prefs`, never from localStorage directly.
const adopted = React.useRef(false);
React.useEffect(() => {
if (adopted.current) return;
adopted.current = true;
if (prefs.columnVisibility) setColumnVisibility(prefs.columnVisibility);
if (prefs.columnOrder) setColumnOrder(prefs.columnOrder);
if (prefs.columnSizing) setColumnSizing(prefs.columnSizing);
if (prefs.density) setDensity(prefs.density);
if (prefs.sorting) setSorting(prefs.sorting);
if (prefs.columnPinning) {
setColumnPinning(reconcilePinning(columns, prefs.columnPinning, known));
}
}, [prefs, columns, known]);
// Write layout back. Note what is NOT here: rowSelection, globalFilter and
// pageIndex are absent by design — see RULE 7.
React.useEffect(() => {
if (!adopted.current) return;
setPrefs({ columnVisibility, columnOrder, columnSizing, columnPinning, density, sorting });
}, [columnVisibility, columnOrder, columnSizing, columnPinning, density, sorting, setPrefs]);
const table = useReactTable({
data: visibleRows,
columns,
state: {
sorting,
globalFilter,
rowSelection,
columnVisibility,
columnOrder,
columnSizing,
columnPinning,
},
onSortingChange: setSorting,
onGlobalFilterChange: setGlobalFilter,
onRowSelectionChange: setRowSelection,
onColumnVisibilityChange: setColumnVisibility,
onColumnOrderChange: setColumnOrder,
onColumnSizingChange: setColumnSizing,
onColumnPinningChange: setColumnPinning,
/* -----------------------------------------------------------------------
* RULE 1 — SET globalFilterFn EXPLICITLY. THIS IS THE #1 SILENT DEFECT.
*
* TanStack's default is 'auto', which declines to filter any column whose
* value is not a string. With mixed types that is most columns, so the
* search box ACCEPTS INPUT AND CHANGES NOTHING. No error, no warning.
*
* WHO GOT THIS WRONG: me, in this very demo, on the first pass. Also the
* predecessor of ETL's wrapper, where every accessor returned the whole
* row object so no string column existed at all and search was inert for
* the life of the component.
* -------------------------------------------------------------------- */
globalFilterFn: 'includesString',
/*
RULE 13 — REGISTER anshinColumnFilterFn AS THE DEFAULT COLUMN FILTER.
`ColumnFilterPopup` writes a STRUCTURED value: `{ operator, value }`.
TanStack's default filterFn ('auto' → includesString) stringifies that to
"[object object]" and matches NOTHING — so every per-column filter
silently emptied the table. Measured on /documents: filtering Title
contains "Policy" took 240 rows to 0, with rows plainly matching.
The control worked, the value was stored, and the answer was always "no
results" — which is indistinguishable from a table that genuinely has no
matches. Reported by Bryan 2026-09-29.
*/
defaultColumn: { filterFn: anshinColumnFilterFn },
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
enableRowSelection: true,
enableColumnResizing: true,
/* RULE 5 — 'onChange' resizes live. The default 'onEnd' only applies the
* width on pointer-up, which reads as a broken drag handle. */
columnResizeMode: 'onChange',
/* RULE 6 — BOTH lines are required for multi-column sort. enableMultiSort
* alone does nothing without a multi-sort event predicate, and
* toggleSorting's SECOND argument is what actually appends rather than
* replaces. Bryan reported multi-sort as "not working" against 5.2.0 —
* the header was calling toggleSorting(desc) with one argument. */
enableMultiSort: true,
isMultiSortEvent: (e) => (e as unknown as MouseEvent).shiftKey,
initialState: { pagination: { pageSize: 15 } },
});
/* -------------------------------------------------------------------------
* RULE 2 (continued) — RENDER THE ERROR STATE. IT IS A DIFFERENT SCREEN.
*
* WHO GOT THIS WRONG: three teams independently, which is why this is a
* library defect and not three app bugs.
* anshin-etl/app/components/DataTable.tsx the string "error" 0 times
* anshin-automate-sdk .../executions/page.tsx
* anshin-automate-sdk .../workflows/page.tsx
* anshin-wiki-sdk/demo/components/udt.tsx
*
* "Your filter matched nothing" and "we could not reach the server" are not
* the same sentence. Collapsing them tells an operator there is no data when
* the truth is that we failed to ask. That is the same failure shape as a
* CI check that cannot distinguish "no" from "could not compute".
* ---------------------------------------------------------------------- */
if (error) {
return (
<div
role="alert"
className="flex flex-col items-start gap-3 rounded-lg border border-destructive/40 bg-destructive/5 p-6"
>
<p className="font-medium text-destructive">Could not load this table.</p>
<p className="max-w-prose text-sm text-muted-foreground">
This is deliberately <strong>not</strong> the empty state. The request failed —{' '}
<code className="text-xs">{error.message}</code> — and showing “no results”
here would report an absence of data that has not been established.
</p>
{onRetry && (
<Button variant="outline" size="sm" onClick={onRetry}>
Retry
</Button>
)}
</div>
);
}
const handleExport = (format: ExportFormat, selectedOnly?: boolean) => {
// No bridge, no translation table. One union, every member implemented as of 5.3.12 — the
// format the toolbar emits IS the format the exporter takes. RULE 4 is now enforced inside
// the package: unknown members hit a `default:` that throws on a `never` assignment, so an
// unproducible format cannot silently become a CSV here or anywhere else.
const fmt = format;
const source = selectedOnly
? table.getSelectedRowModel().rows
: table.getFilteredRowModel().rows;
const out = source.map((r) => r.original as unknown as Record<string, unknown>);
const headers = table
.getVisibleLeafColumns()
.map((c) => c.id)
.filter((id) => id !== 'select' && id !== 'actions');
downloadBlob(
exportData(out, fmt, { headers, title: surface }),
// RULE 4b — name the file for what it CONTAINS. 'pdf' currently returns
// print HTML; handing someone a .pdf that no reader opens is a defect.
`${surface}.${fmt === 'excel-xml' ? 'xls' : fmt === 'pdf' ? 'html' : fmt}`,
);
};
return (
<div className="space-y-2">
{/*
RULE 9 — THE ROWS-PER-PAGE VALUE DEFINES THE VIEWABLE SURFACE.
This was `h-[70vh] min-h-[420px]`: a fixed height unrelated to how many
rows the user asked for. Choose 10 rows and you got dead space below
them; choose 50 and the grid scrolled inside a box two thirds of the
viewport tall, so the page had one scrollbar and the table another.
Bryan, 2026-09-28: the Rows value "should help to define the viewable
surface of the UDT". So `autoHeight` sizes the grid to its content, and
`maxBodyHeight` caps it at 68vh for large page sizes — beyond that the
body scrolls rather than pushing the toolbar off screen.
*/}
<div className="flex flex-col">
<DataTableSortable
table={table}
density={density}
autoHeight
maxBodyHeight="68vh"
enableColumnResizing
enableColumnDragDrop
enableRangeSelection
columnOrder={columnOrder}
onColumnOrderChange={setColumnOrder}
/* RULE 3 — DO NOT PASS enableFieldSearch OR actionBar.
Both are declared in DataTableSortableProps and appear ZERO times
in the compiled implementation. They typecheck, accept a value and
are unreachable. Reported by OPS-CLIF; confirmed statically by
OPS-Automate-SDK, which is stronger evidence than a behavioural
test for a dead prop. They are being removed. */
>
<DataTableToolbar
table={table}
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder={`Search ${visibleRows.length} rows…`}
advancedFilterCount={filterGroups.reduce((n, g) => n + g.conditions.length, 0)}
onAdvancedFilterChange={setFilterGroups}
density={density}
onDensityChange={setDensity}
columnOrder={columnOrder}
onColumnOrderChange={setColumnOrder}
onExport={handleExport}
onRefresh={onRetry}
/>
</DataTableSortable>
</div>
{/* RULE 10 — expose the sort state. With multi-sort enabled, precedence
is invisible otherwise and users cannot tell why rows are ordered
as they are. */}
<DataTableSortPanel table={table}>
<span className="text-xs text-muted-foreground">
Shift-click a second header to add a sort level. Precedence shown above.
</span>
</DataTableSortPanel>
<p className="text-xs text-muted-foreground">
Layout persists under <code>{prefsKey}</code>. Row selection, search text and page index
are deliberately <strong>not</strong> stored — a filter value is user-entered and may be a
patient name or MRN.
</p>
</div>
);
}
/**
* Column helpers, in the required order.
*
* RULE 11 — createSelectColumn FIRST, createActionsColumn LAST. The pinning
* helpers assume that arrangement; interleaving them is what makes Actions
* jump column when another column is pinned right.
*/
export function canonicalColumns<T extends { id: number | string }>(
body: ColumnDef<T, unknown>[],
actions: Parameters<typeof createActionsColumn<T>>[0],
): ColumnDef<T, unknown>[] {
return [createSelectColumn<T>(), ...body, createActionsColumn<T>(actions)];
}
export { DataTableColumnHeader, formatFileSize, Badge };