Skip to content

refactor: One useTableState hook for five tables #322

Description

@frankieramirez

Problem

Five tables, each with its own state machine. This area has the highest defect density in the repo#290, #292, #293, #307 are all table-state fixes.

Table Lines Row id Selection decode Pagination
series/SeriesTable.tsx 763 row.ComicID (:257) Object.keys(rowSelection).filter(...) (:156) client-side + nuqs URL + shadow useState
series/IssuesTable.tsx 834 default Object.keys(rowSelection).map(...) (:187) TanStack initialState.pagination
import/ImportTable.tsx 568 getImportGroupRowId (:512) ref-based emitSelectionChange server-side offset/limit
queue/WantedTable.tsx 223 row.IssueID (:200) fixed by #307 (:190)
queue/UpcomingTable.tsx 236 row.IssueID (:223) fixed by #307 (:213)

The shared layer is a renderer only — data-table/DataTable.tsx (105 lines) takes an already-built TanstackTable<TData>. All state is per-table.

How #307 happened, twice

WantedTable and UpcomingTable both decoded stable row-id keys as array indices (issues[parseInt(key)]), so selection was always empty. The identical bug shipped in two files because there was no shared implementation to fix once. Both now carry a hand-written comment explaining the invariant — a comment is not a seam.

Filters and pagination

SeriesFilters.tsx renders filters, SeriesTable.tsx:60–75 owns the nuqs parser definitions (including a hand-written sortParser with a custom delimiter at :55), and SeriesTable.tsx:127 applies them. SeriesListPage.tsx recomputes mangaCount/comicCount from the full array — a fourth place that knows ContentType === "manga". #290 moved filter ownership into SeriesTable; #293 then had to reset pagination when switching views; #292 added the URL/shadow-state sync at :95–104.

Action bars

Three separate ones: queue/BulkActionBar.tsx (72), import/ImportBulkActions.tsx (114), and a third rendered inline at SeriesTable.tsx:412–420. #290's stated goal was "unify table action bar" — it unified the toolbar, not the bulk bar.

Proposed deepening

A useTableState<T> hook. Interface:

useTableState<T>({ rows, getRowId, pagination }) -> { table, selectedIds, page, setPage }

Implementation absorbs selection decoding, the page-clamp rule, sort state, and the nuqs URL-sync dance.

Explicitly a hook, not a schema engine. #308 deleted 7,447 lines / 65 files of a schema-driven table system that was unreachable, @ts-nocheck'd, and inside the eslint ignore list — an over-general abstraction built without callers. The lesson is to share the state, not to generate the tables.

Wins

  • locality: fix: Decode queue table selection by row identity #307-class bugs fix once, fixed everywhere
  • 3 selection decoders → 1; 3 pagination models → 1 deliberate choice per table
  • The URL-sync dance becomes a tested invariant instead of a bug fix
  • leverage: one interface, 5 tables

Deletion test

Passes. Keep DataTable.tsx — a renderer is correctly shallow by design.

Test gap

13 test files for 105 components. IssuesTable.tsx (834 lines) and UpcomingTable.tsx have no direct tests. Every existing table test is a post-hoc regression pin, one per incident. The hook's interface becomes the test surface for all five.


From the architecture review, candidate 8 (Strong). Independent of the backend candidates — can run in parallel.

Metadata

Metadata

Assignees

No one assigned

    Labels

    architectureArchitectural deepening / structural refactorenhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions