Filterable List Pattern — Substrate Choice

1. Overview

Every admin-portal list screen in EMS — Race Number Inventory, Event Participants, Memberships, Orders, Persons — is the same shape: a paginated table over a JHipster Specification-filtered REST endpoint, with column-level filters, multi-column server-side sort, and a route that may pin some filters via path or query parameters. Building each screen as a one-off would be ~200 lines of repetitive HTML plus bespoke filter handling per entity.

This page records the architectural decision behind the substrate — the table library and host markup the pattern is built on — together with the alternatives we evaluated and the rationale. The companion document Filterable List Design specifies the pattern itself: the column-as-parent abstraction, the JHipster query adapter, the column-filter widgets, the path-vs-query sourcing rules, and the recipe for adding a new list screen.

The full evaluation history — including a 4-day prototype that built both leading candidates side-by-side on the Race Number Inventory worked example — is in the design journal.

2. Decision

Adopt Tanstack @tanstack/angular-table with hand-written Bootstrap 5 markup as the substrate for all admin-portal list screens.

The shared layer (column config, JHipster query adapter, filter widgets, active-filter chip strip) is library-agnostic and survives any future substrate change. Tanstack runs in fully manual mode (manualPagination, manualSorting, manualFiltering) — the back-end does the work; Tanstack contributes the column model and the sort/filter state machinery; Bootstrap + ng-bootstrap contribute the DOM.

3. Forces

  1. Visual cohesion with the Bootstrap host. admin-portal — like registration-portal — is built on Bootstrap 5 + ng-bootstrap. A list-table substrate that imposes its own design language (Material, PrimeNG Aura) introduces a second visual vocabulary that every list screen pays a per-component theming cost to reconcile.

  2. Bundle size of an admin SPA. admin-portal will host dozens of list screens. Per-list incremental cost compounds across a working day for the team that uses it daily.

  3. JHipster API contract is fixed. Every back-end criterion exposes ?fieldName.equals=…​ / .in=…​ / .contains=…​ / .greaterThanOrEqual=…​ / .lessThanOrEqual=…​ / .specified=…​, plus ?page=N&size=M&sort=field,asc. The substrate must talk this contract or front-load a per-screen translation layer.

  4. URL is the canonical state. List state must round-trip through the URL — bookmarkable, shareable, deep-linkable from external systems. Any substrate that hides state in component memory adds work to keep URL and table in sync.

  5. Path-param pinning matters. Some list screens are accessed in the context of a parent (/event/:eventId/race-numbers). The pinned filter is sourced from the path; the column for that filter is hidden on that route. The substrate must allow our visibility logic to drive whether a column renders.

  6. Multi-tenant admin work — fast lists win. Operators triage hundreds of rows daily. Lazy-load contracts, server-side sort, multi-sort, and good column-filter UX are not optional.

4. Alternatives Considered

The full evaluation is in the design journal. Summary, grouped by philosophical camp:

4.1. Camp A — Full-component (kitchen-sink) libraries

Library Outcome

PrimeNG <p-table>

Runner-up. Mature, rich LazyLoadEvent, built-in column-filter popovers (text / numeric / date / dropdown / multi-select), multi-sort, column toggling. Adopted as the comparison anvil. Lazy-chunk transfer 101 kB gz vs Tanstack’s 27 kB (~3.7×). Aura theme is closer to Bootstrap than alternatives but visibly different at button hovers, popover chrome, and corner radii. Required cssLayer config + a .primeng-host wrapper to keep style bleed contained. Loses on visual fidelity criterion despite strong feature parity.

AG Grid Community

Cut early. Server-side row model — the killer feature for JHipster pagination — is Enterprise-only (commercial licence). Community edition’s infinite row model is workable but its DOM is the heaviest of the three to live with, and Bootstrap-first apps don’t get on with AG Grid’s idioms.

Angular Material <mat-table>

Cut early. No built-in column-filter UI — you build the very thing the comparison was supposed to evaluate. Lazy-loading isn’t native to MatTableDataSource. Material design clashes harder with Bootstrap than PrimeNG does.

Kendo / Syncfusion / Telerik

Out. Commercial licences not justified.

4.2. Camp B — Headless logic libraries

Library Outcome

Tanstack @tanstack/angular-table

Selected. Provides the sort / filter / pagination state machines as Angular signals; brings no DOM, no theme, no opinions. Pairs natively with Bootstrap + ng-bootstrap because there is nothing to fight. Lazy-chunk transfer 27 kB gz. Costs ~120 LOC more per screen than PrimeNG (~22%) — the price of writing the table markup, paid in code maintainers can read without learning a second UI library’s conventions.

4.3. Camp C — Lightweight hooks (or hand-rolled)

Library Outcome

ngx-datatable (Swimlane)

Cut. Maintenance momentum has slowed. No built-in column-filter UI. Same column-filter gap as Material with less library polish.

Hand-rolled Bootstrap table (admin-ui’s status quo)

Cut. Every missing piece (column-filter popovers, column toggling, multi-sort UI, lazy-load contract) is on us. The shared-layer extraction here would still be valuable, but without a state machine for sort/filter we’d reinvent what Tanstack provides headless.

5. Rationale for Tanstack

In priority order:

  1. Visual fidelity is decisive. admin-portal is being built on Bootstrap + ng-bootstrap. Adopting PrimeNG would mean two design vocabularies in one app for the rest of its life, or a recurring per-component customisation cost to make it feel native. Tanstack imposes nothing visually. This is the criterion that separated the candidates cleanly.

  2. The bundle delta is real and compounding. 74 kB gz per list-screen-equivalent compounds across an admin SPA. For a team-wide tool that loads N list screens during a working day, this is felt.

  3. The LOC penalty is tolerable and bounded. The extra ~120 LOC per screen lives in a Bootstrap-flavoured template that future maintainers can read without learning Tanstack-specific conventions. The shared layer absorbs the per-entity variance.

  4. The choice is reversible. Both prototypes ran on the same shared layer. Substrate-specific code is bounded to a small state-translation adapter (~100 LOC) plus the table component itself (~500 LOC). If a future force flips the calculus, the cost of switching is visible.

PrimeNG’s headline feature — built-in column-filter popovers — turned out to be modest leverage in practice. The pTemplate="filter" slot still needs a @switch over our six facet kinds, the same widgets, the same apply / clear semantics. PrimeNG saves popover chrome, not per-facet input logic. ng-bootstrap dropdowns replace that chrome at ~30 LOC per screen. Multi-sort UX is equivalent on both substrates.

6. Caveats

  • $localize polyfill is mandatory. ngb-pagination (used for paging) emits $localize-tagged ARIA labels and crashes the template render the moment totalRecords > pageSize if the polyfill is absent. Surface as polyfills: ["@angular/localize/init"] in angular.json (not as a direct import in main.ts — Angular CLI warns and behaviour is undefined). Required wherever a list lands.

  • Tanstack reactivity gotchas. The state signal must use value equality (otherwise fromRoute produces a fresh TableState reference on every router emit and the fetch effect re-fires); getCoreRowModel() must be hoisted to a stable instance (calling it inside the createAngularTable options factory breaks row-model memoisation); each ColumnDef needs an accessorFn. All three are baked into the prototype implementation that admin-portal mirrors.

  • Server-side row model only. The pattern assumes the back-end does filtering, sorting, and pagination. Client-side filtering is not in scope and would be a different pattern (and a different substrate decision). For data sets small enough to fit in the browser, a static table is simpler and cheaper.

7. Status

The pattern is active and evolving. The prototype at ~/dev/ems/prototypes/filterable-list/ is the upstream source of truth for the shared layer; admin-portal mirrors it with one-line upstream-pointer comments at the top of each file. Refinements surfaced by new list screens are designed in the prototype first (with unit tests), then mirrored downstream.

The prototype retires once 1–2 admin-portal list-screen iterations have stabilised the surface and this document set is maintained as the living reference. See the journal’s promotion model for the discipline gate.

8. References