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, feedssetPageCount - Nested routes: When a table renders in an index child but the search schema lives on the parent route, pass the parent route id to
useTableUrlState.fromso validated URL filters (for exampleplatform_name) are read and preserved. - Columns: In feature config files, pure render logic, stable column ids, action columns via
useMemo - Row actions: 2+ row actions go behind a single
MoreHorizontaldropdown trigger, not a row of individual icon buttons. Extract a<feature>-actions-cell.tsxcomponent (e.g.studio-member-actions-cell.tsx,studio-creator-actions-cell.tsx) that composes the sharedDataTableActionsprimitive (@eridu/ui) — passonEdit/onDeleteas named props, everything else viarenderExtraActions(toggle-style actions like retire/reactivate, navigation links, custom mutations). The actions column'scellin the*-columns.tsxfile just renders that component. A row of standalone iconButtons for 2+ actions is the anti-pattern this replaces — it doesn't scale past 2-3 actions and is inconsistent with every other table in the app. Seemechanic-actions-cell.tsxfor a reference conversion (PR 20.8). - Toolbar: Use
DataTableToolbar— primary search maps to URL-backed filter, debounced, manual refresh with icon-only button +aria-label. If the view requires custom filters (like clients, show types, platforms) alongside search, integrate the responsive Popover/Sheet triggers directly as children ofDataTableToolbar(sizing buttons down toh-8to align with the search input) to provide a consistent, integrated toolbar UX. - Pagination: Use
DataTablePagination—useTableUrlStateownspage/pageSize,placeholderData: keepPreviousData, never clamp against fallback during loading
Pagination Review Gate
- Manual
page/limitreads → ask why notuseTableUrlState - 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
displayedDatafrom a larger merged dataset than the hook's server query, the hook'ssetPageCountwill clamppageIndexto 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:
// After computing pageCount from the merged filteredAllData:
useEffect(() => {
// Guard: only override after the secondary dataset resolves.
// While loading, leave useTableUrlState on the server count to avoid
// premature clamping to pageCount=1 (empty dataset).
if (summaryData !== undefined) {
setPageCount(pageCount); // pageCount = Math.ceil(mergedData.length / pageSize)
}
}, [pageCount, summaryData, setPageCount]);
Key rules:
- The guard (
summaryData !== undefined) prevents the loading state (merged data = []) from clampingpageIndexto 1. - After the secondary dataset resolves, this effect always wins: it runs after the hook's internal
setPageCount(serverTotalPages)within the same render cycle. effectivePaginationpassed toDataTableandDataTablePaginationmust be derived from the merged count, nottableProps.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:
useTableUrlStatefor 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
useSelectedRowSnapshotsfeature hook
Row Selection Eligibility
When a table has both issue badges and bulk actions, keep the two decisions separate:
- Issue helpers can return advisory review signals that explain row state to the user.
- Bulk-action selection must use a dedicated blocker helper that returns only conditions the bulk endpoint cannot process.
- Do not reuse all issue badges as
enableRowSelection; advisory warnings such as extraction visibility, binding drift, or stale-template context can block a valid backend action. - If a list payload omits large lazy-loaded fields such as
snapshot.schema, do not infer a negative condition from absence alone. Run schema-dependent checks only when the field is actually present, or lazy-load detail before making a blocking decision.
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 thesearchableColumnsconfig. Provideoptions,isLoading,onSearchlisteners, and aplaceholder. - Trigger Label Persistence: Always implement a secondary query (e.g., a
by-idorby-namequery withlimit: 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), passingfetchList,fetchSelected(must returnnull, neverundefined), and a stabletoOption. 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
AbortSignalto 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
- Shared
DataTableprimitives reused unless justified exception - URL state owned by
useTableUrlState - Feature hook owns query/filter/refresh state
- Route search params validated
-
isLoadingandisFetchingboth handled - Mutation invalidation scoped correctly
- Stable row ids for selection/editing
- Row-selection eligibility uses hard blockers, not every issue badge or advisory warning
- 2+ row actions use a
DataTableActionsdropdown actions-cell, not standalone icon buttons - Current-view export (if present) uses shared params +
AbortSignal+ shared CSV/download helpers + concurrency cap (noPromise.allfan-out) + spinner on trigger - Route decomposition clean and maintainable
- Layout compared against nearest canonical table
Related Skills
- operations-review-surface — Multi-tab operational-day review screens composed on top of these table primitives
- studio-list-pattern — Card-based infinite scroll
- frontend-ui-components — Shared UI primitives
- frontend-state-management — State patterns
- frontend-performance — Virtualization, memoization
- admin-list-pattern — Admin table lists