Universal Data Table — required implementation

SEEDED FIXTURE — baked at build time, NOT live. 240 deterministic rows in the shape of ui_test_db.

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 v1Memo
Archived
Iris SatoClinical Operations1.4 MB3.27
clinical
Yes82026-09-19
Contract Procedure v2Contract
Archived
Sanjay JansenRevenue Cycle567.3 KB4.62
training
No42026-08-12
Report Review v2Report
Approved
Tomas EriksenEngineering2.5 MB—
vendor
No72026-07-30
Policy Procedure v1Policy
Approved
Rosa JansenInformation Security4.1 MB1.78
clinical
No82026-08-19
SOP Review v4SOP
Draft
Uma EriksenEngineering2.4 MB4.07
audit
vendor
+1
Yes22026-08-16
Memo Policy v3Memo
In Review
Kofi DialloRevenue Cycle3.6 MB—
training
No52026-09-30
Report Standard v5Report
In Review
Kofi JansenEngineering4.5 MB4.17
incident
No82026-10-01
SOP Procedure v4SOP
Archived
Iris SatoCompliance2.6 MB4.16
hipaa
onboarding
Yes102026-07-25
Policy Policy v4Policy
Approved
Kofi JansenClinical Operations3.2 MB—
clinical
training
Yes72026-07-31
SOP Review v2SOP
In Review
Dana OkaforCompliance2.9 MB4.07
training
Yes102026-08-25
Policy Policy v5Policy
Approved
Uma EriksenEngineering4.1 MB4.61
hipaa
Yes22026-07-13
Memo Procedure v4Memo
Archived
Sanjay JansenCompliance1.6 MB2.36
clinical
No52026-07-28
Memo Guideline v4Memo
In Review
Dana OkaforRevenue Cycle2.1 MB—
audit
incident
No62026-07-12
Memo Guideline v3Memo
Approved
Kofi DialloInformation Security1.1 MB1.37
clinical
No62026-07-06
Contract Standard v5Contract
Draft
Dana SatoHuman Resources4.1 MB1.72
audit
No42026-08-07
240 row(s) total.
Page 1 of 16
Rows
Shift-click a second header to add a sort level. Precedence shown above.

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

#RuleWhat happens if you skip itProved by
1Set globalFilterFn: 'includesString' explicitlyTanStack'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
2Render 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
3Do not pass enableFieldSearch or actionBarBoth 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)
4Refuse export formats you cannot produce — never substituteA 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)
5columnResizeMode: 'onChange'The default 'onEnd' applies width only on pointer-up, which reads as a broken drag handle.Bryan, against 5.2.0
6enableMultiSort AND isMultiSortEvent — bothEither alone does nothing. toggleSorting also needs its second argument to append rather than replace.Bryan, reported multi-sort “not working”
7Persist layout only, under a versioned keyPersisting 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
8Pin via deriveInitialPinning / reconcilePinningmeta.pinned is inert alone. Pinning any column right shunts the Actions column to the left.Bryan, against 5.2.1
9Give the table a bounded heightThe page grows without limit and the toolbar scrolls out of reach.Bryan, against 5.2.1
10Expose the sort state when multi-sort is onSort precedence is invisible and users cannot tell why rows are ordered as they are.Bryan, against 5.2.1
11Select column first, Actions column lastThe 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 &ldquo;no results&rdquo;
          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 };
Brand