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?
| Scenario | Component | Why |
|---|
| Simple list, < 100 rows | DataTable | Easy setup, backward compatible |
| Moderate data, pagination needed | DataTable with pagination | Standard approach |
| 500+ rows, all in memory | VirtualizedTable | Prevents DOM bloat |
| Server-side pagination | DataTable + useServerTable | Efficient data fetching |
| Complex features (grouping, inline edit) | TanStack native + hooks | Full 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 });
},
});
// ✅ 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
-
Horizontal scroll (default)
- Works for most tables
- Add visual indicator for scrollable content
-
Column hiding
- Hide non-essential columns on mobile
- Use column visibility toggle
-
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