Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.encoreos.io/llms.txt

Use this file to discover all available pages before exploring further.

Version: 1.0.0
Last Updated: 2026-01-17
Status: ✅ Active

Overview

This guide provides best practices for implementing tables in the Encore Health OS platform using the @/platform/table-v2 framework.

Quick Decision Guide

Which Component to Use?

ScenarioComponentWhy
Simple list, < 100 rowsDataTableEasy setup, backward compatible
Moderate data, pagination neededDataTable with paginationStandard approach
500+ rows, all in memoryVirtualizedTablePrevents DOM bloat
Server-side paginationDataTable + useServerTableEfficient data fetching
Complex features (grouping, inline edit)TanStack native + hooksFull control

When to Use Virtualization

Use virtualization when:
  • Row count exceeds 500+
  • Users frequently load full datasets
  • Performance is degrading on scroll
Skip virtualization when:
  • Row count is consistently < 100
  • Server-side pagination is used
  • Mobile is the primary target (pagination is more user-friendly)

Feature Selection Guide

Export (useTableExport)

Add export when:
  • Users need to share data externally (spreadsheets, reports)
  • Compliance/audit requirements exist
  • Data analysis workflows are common
Implementation:
import { useTableExport } from '@/platform/table-v2';

const { exportToCsv, exportToExcel, isExporting } = useTableExport({
  defaultFilename: 'accounts-export',
  alwaysExcludeColumns: ['actions'],  // Never export action buttons
});

// Add button to toolbar
<Button onClick={() => exportToCsv(table)} disabled={isExporting}>
  <Download className="h-4 w-4 mr-2" />
  Export CSV
</Button>
Best Practices:
  • Always exclude actions column from exports
  • Use descriptive filenames with date context
  • Show loading state for large exports
  • Consider Excel for formatted data (headers, number formatting)

Column Pinning (useColumnPinning)

Pin columns when:
  • Tables have many columns requiring horizontal scroll
  • Key identifiers (ID, name) should always be visible
  • Action buttons should be accessible without scrolling
Implementation:
import { useColumnPinning } from '@/platform/table-v2';

const { columnPinning, setColumnPinning } = useColumnPinning({
  persistenceKey: 'bills-table',
  initialPinning: { left: ['id'], right: ['actions'] },
});
Best Practices:
  • Pin ID/identifier columns to the left
  • Pin action columns to the right
  • Limit pinned columns to 2-3 maximum
  • Use persistence key for user preference retention

Row Grouping (useRowGrouping)

Use grouping when:
  • Data has natural categories (status, type, period)
  • Aggregate values add insight (totals, counts, averages)
  • Users need to focus on subsets of data
Implementation:
import { useRowGrouping } from '@/platform/table-v2';

const { groupedData, toggleGroup } = useRowGrouping({
  data: entries,
  groupByKey: 'period',
  aggregates: [
    { column: 'debit', type: 'sum', label: 'Total Debits' },
    { column: 'credit', type: 'sum', label: 'Total Credits' },
  ],
});
Best Practices:
  • Limit grouping to 1-2 levels
  • Show aggregates that provide actionable insights
  • Default to collapsed for large datasets
  • Persist expanded state in localStorage

Inline Editing (useInlineEdit)

Use inline editing when:
  • Quick edits are common workflow
  • Context switching (modals) slows users down
  • Fields are simple (text, number, select)
Avoid inline editing when:
  • Complex validation is required
  • Changes have significant side effects
  • Audit trails need confirmation dialogs
Implementation:
import { useInlineEdit } from '@/platform/table-v2';

const { editingCell, startEdit, commitEdit, cancelEdit } = useInlineEdit({
  onSave: async (rowId, columnId, value) => {
    await updateRecord(rowId, { [columnId]: value });
  },
});

Performance Best Practices

1. Always Use Pagination for Server Data

// ✅ Good - paginate on server
const { data } = await supabase
  .from('records')
  .select('*', { count: 'exact' })
  .range(offset, offset + pageSize - 1);

// ❌ Bad - fetch all, paginate client-side
const { data } = await supabase.from('records').select('*');
const pageData = data.slice(offset, offset + pageSize);

2. Memoize Column Definitions

// ✅ Good - memoized columns
const columns = useMemo<ColumnDef<Account>[]>(() => [
  { accessorKey: 'name', header: 'Name' },
  { accessorKey: 'balance', header: 'Balance' },
], []);

// ❌ Bad - recreated on every render
const columns: ColumnDef<Account>[] = [
  { accessorKey: 'name', header: 'Name' },
  { accessorKey: 'balance', header: 'Balance' },
];

3. Use Skeleton Loaders

// ✅ Good - skeleton while loading
if (isLoading) {
  return <TableSkeleton rows={10} columns={5} />;
}

// ❌ Bad - empty or null while loading
if (isLoading) return null;

4. Optimize Render Functions

// ✅ Good - simple render function
render: (value) => formatCurrency(value)

// ❌ Bad - complex component in render
render: (value, row) => (
  <ComplexComponent 
    value={value} 
    row={row} 
    onAction={handleAction}
    options={options}
  />
)

Mobile Considerations

Responsive Table Patterns

  1. Horizontal scroll (default)
    • Works for most tables
    • Add visual indicator for scrollable content
  2. Column hiding
    • Hide non-essential columns on mobile
    • Use column visibility toggle
  3. Card layout
    • For data-dense tables
    • Switch to vertical card layout on mobile

Touch Targets

// ✅ Good - adequate touch target
<Button className="h-10 w-10 min-h-[44px] min-w-[44px]">
  <Edit className="h-4 w-4" />
</Button>

// ❌ Bad - too small for touch
<Button className="h-6 w-6">
  <Edit className="h-3 w-3" />
</Button>

Common Patterns

Filter + Table Pattern

function DataTableWithFilters() {
  const [filters, setFilters] = useState<Filters>({});
  const { data, isLoading } = useQuery({
    queryKey: ['data', filters],
    queryFn: () => fetchData(filters),
  });

  return (
    <div className="space-y-4">
      <TableFilters filters={filters} onChange={setFilters} />
      <DataTable data={data} columns={columns} isLoading={isLoading} />
    </div>
  );
}

Bulk Actions Pattern

function TableWithBulkActions() {
  const [rowSelection, setRowSelection] = useState({});
  const selectedCount = Object.keys(rowSelection).length;

  return (
    <>
      {selectedCount > 0 && (
        <BulkActionBar 
          count={selectedCount}
          onDelete={() => handleBulkDelete(Object.keys(rowSelection))}
          onClear={() => setRowSelection({})}
        />
      )}
      <DataTable 
        selection={{ enabled: true, rowSelection, onRowSelectionChange: setRowSelection }}
      />
    </>
  );
}

Empty State Pattern

<DataTable
  data={data}
  columns={columns}
  emptyState={{
    icon: <FileText className="h-12 w-12 text-muted-foreground" />,
    title: 'No invoices yet',
    description: 'Create your first invoice to get started.',
    action: (
      <Button onClick={handleCreate}>
        <Plus className="h-4 w-4 mr-2" />
        Create Invoice
      </Button>
    ),
  }}
/>

Anti-Patterns to Avoid

❌ Fetching All Data Client-Side

// Bad - loads everything into memory
const { data } = useQuery({
  queryKey: ['all-records'],
  queryFn: () => supabase.from('records').select('*'),
});

❌ Recreating Table Instance

// Bad - new instance on every render
function MyTable({ data }) {
  const table = useReactTable({ data, columns, ... });
  return <BaseTable table={table} />;
}

❌ Complex Logic in Column Accessors

// Bad - expensive computation in accessor
{
  accessorFn: (row) => {
    const result = expensiveCalculation(row);
    return formatComplexValue(result);
  },
}

❌ Missing Loading States

// Bad - no feedback during load
if (!data) return <DataTable data={[]} />;

References