| name | table-view-pattern |
| description | Provides patterns for building and optimizing large frontend tabular views across the eridu-services monorepo. Use when building or refactoring server-driven tables, dense tabular UIs, virtualized grids, inline editing, saved views, and table URL state in erify_creators, erify_studios, or shared @eridu/ui. |
Table View Pattern
Standard patterns for large tabular views in erify_studios, erify_creators, and @eridu/ui.
See references/table-view-details.md for extended guidance, code examples, virtualization patterns, CRUD consistency rules, current-view export details, and anti-patterns.
Read First
- Tech debt register:
apps/erify_studios/docs/FRONTEND_TECH_DEBT.md
- Nearest existing table route in target app before changing code
When to Use / Not Use
Use: Large table routes, migrating lists to tables, optimizing dense UIs, adding row actions/selection/editing, modifying @eridu/ui table primitives.
Don't use: Card-based studio lists → studio-list-pattern. Backend-only pagination → backend skills. Trivial CRUD with small row counts → use shared DataTable without extra complexity.
Core Principles
- Shared primitives first:
DataTable + DataTableToolbar + DataTablePagination + useTableUrlState + TanStack Query + TanStack Router search validation. No custom grids unless justified.
- Route-driven views over data: Server state (TanStack Query), URL state (
useTableUrlState), local UI state (dialogs, selected row id). Never collapse all into one component state object.
- Preserve route/search contracts: Validate search params in route, keep URLs shareable, reset page to 1 on filter change.
- Follow repo decomposition: Route = composition boundary, feature hook = query + URL state, columns = config files.
- Match nearest canonical table: Reuse layout, toolbar density, search/filter contract from closest existing table before inventing a new variant.
Decision Order
- Choose primitive: Default
DataTable → virtualized only if measurably slow → card grids only if not tabular
- Choose ownership: Route owns composition, feature hook owns query, column config owns renders, shared package owns primitives
- Decide explorer features: Only add saved views, inline editing, column visibility, virtualization when product actually needs it
Standard Table Pattern
- Route:
createFileRoute(...)({ validateSearch, component }) — focused on composition
- Feature hook: Owns
useTableUrlState, maps to API params, executes queries, handles refresh/invalidation, feeds setPageCount
- Columns: In feature config files, pure render logic, stable column ids, action columns via
useMemo
- Toolbar: Use
DataTableToolbar — primary search maps to URL-backed filter, debounced, manual refresh with icon-only button + aria-label
- Pagination: Use
DataTablePagination — useTableUrlState owns page/pageSize, placeholderData: keepPreviousData, never clamp against fallback during loading
Pagination Review Gate
- Manual
page/limit reads → ask why not useTableUrlState
- Missing
keepPreviousData → ask why
- Custom pagination buttons → ask why not
DataTablePagination
- Fallback clamps during loading → correctness bug
- Merged-dataset page count mismatch — if the route derives
displayedData from a larger merged dataset than the hook's server query, the hook's setPageCount will clamp pageIndex to the smaller server range; override it with the merged count (see § Merged-Dataset Pagination below)
Merged-Dataset Pagination
Use when a route renders a client-side union of two server queries (e.g. due-dated tasks + undated tasks) and paginates the combined result locally.
Problem: The feature hook calls setPageCount(data.meta.totalPages) from its server query. useTableUrlState auto-corrects pageIndex against that count. When the merged dataset has more rows — and thus more pages — than the server query alone, those extra pages are silently unreachable.
Fix: Expose setPageCount from the feature hook return, thread it through the controller, then call it from the route once the secondary dataset has resolved:
useEffect(() => {
if (summaryData !== undefined) {
setPageCount(pageCount);
}
}, [pageCount, summaryData, setPageCount]);
Key rules:
- The guard (
summaryData !== undefined) prevents the loading state (merged data = []) from clamping pageIndex to 1.
- After the secondary dataset resolves, this effect always wins: it runs after the hook's internal
setPageCount(serverTotalPages) within the same render cycle.
effectivePagination passed to DataTable and DataTablePagination must be derived from the merged count, not tableProps.pagination.
Reference implementation: apps/erify_studios/src/routes/studios/$studioId/task-review/index.tsx
State Rules
- Server state: TanStack Query with stale-while-revalidate
- URL state:
useTableUrlState for page, limit, sortBy, sortOrder, search, date ranges, filters
- Local state: Selected row id (not full objects), dialog/drawer open state, draft inline edits
- Row selection surviving page changes: use
useSelectedRowSnapshots feature hook
Async Combobox Filters
For filters that query large backend collections (e.g., Clients, Memberships/Users, Shows) in dense tables:
- Combobox Configuration: Use
type: 'combobox' inside the searchableColumns config. Provide options, isLoading, onSearch listeners, and a placeholder.
- Trigger Label Persistence: Always implement a secondary query (e.g., a
by-id or by-name query with limit: 1) to fetch the full object details of the active filter value. This ensures the correct label remains visible on the trigger button even when the item falls outside the initial page of search results.
- Client-Side/Server-Side Harmony: Ensure the option value maps correctly to the column filters state, and that client-side filtering (if applicable) or server-side filtering handles the resolved values cleanly.
- Reuse the shared hook: Don't hand-roll the list + selected-label query pair. Use
useAsyncComboboxFilter (apps/erify_studios/src/features/tasks/hooks/use-async-combobox-filter.ts), passing fetchList, fetchSelected (must return null, never undefined), and a stable toOption. See the task-review filter hooks (use-task-review-client-filter.ts, -user-filter.ts, -show-filter.ts) as reference implementations.
Current-View Export
When a table supports CSV/JSON export, export the current server-filtered view, not just the visible page:
- Derive export params from the same hook-owned API params as the table query; omit only
page/limit.
- Page through the list endpoint with a fixed export page size and a documented max row cap.
- Cap concurrent page fetches at a small constant (e.g. 4). Do not fan out every remaining page with
Promise.all — accept some wait time over bursting up to N simultaneous requests at the API.
- Forward an
AbortSignal to every page request; abort in-flight exports on unmount or new export.
- Show a spinner + "Exporting…" label on the trigger button while pagination runs (do not leave the button silently disabled).
- Use shared primitives (
src/lib/csv.ts, src/lib/file-download.ts) for escaping, UTF-8 BOM, CRLF, and downloads.
- Disable the export action when the matching count is zero or an export is already running.
See references/table-view-details.md for reference implementations.
Checklist
Related Skills