The table state hook implements useTableState, including the query-string contract
and the cell and filter vocabularies. Everything lives in packages/ui: the query-string codec
in src/hooks/table_query.ts, the localStorage store in
src/hooks/column_preferences.ts, the hook in src/hooks/use_table_state.ts,
the components in src/components/table/, src/components/cells/ and
src/components/filters/.
Server halves: the query pipeline (§8.3) and table columns (§7.5/§8.1).
The URL is the state
There is no table state anywhere except the address bar. useTableState parses
page.url on every render and every mutation writes a new URL:
const table = useTableState(descriptor)
table.state // { search, filters, sort, page, perPage, cursor, columns, trashed }
table.setSort('publishedAt') // → /admin/posts?sort=publishedAtThat is what makes the back button work without any history bookkeeping of our own, and what makes a copied URL reproduce a colleague’s screen exactly. A shadow copy in React state would be a second source of truth and would drift the first time the user navigated history.
The one exception is searchDraft: a controlled input cannot wait 300 ms for a
round trip before showing a keystroke. It is re-synced from the URL whenever no
keystroke is queued, so history navigation still wins.
Every mutation is a partial reload
All setters funnel through one visit:
router.get(url, {}, { only: ['records'], preserveScroll: true, preserveState: true })only: ['records'] is the whole point (§10.2): a sort click must not
re-serialize the descriptor, which is the expensive half of the response. Search
is debounced 300 ms; nothing else is, because nothing else fires per keystroke.
isLoading is driven by the visit’s own onFinish, so the loading affordance
covers exactly the requests this hook issued and nothing else on the page.
Allowlists, mirrored
parseTableQuery drops unknown sort keys, unknown filter keys, unknown column
keys and out-of-range perPage values — silently, exactly as the server does
(§8.3). It is a deliberate duplicate of parseIndexQueryState
(packages/core/src/table/pipeline.ts): if the two disagreed about what a URL
means, the table would render controls the server ignores.
Serialization is the inverse and is ordered and default-eliding: page=1,
perPage=<default> and trashed=default are omitted, and keys are emitted in a
fixed order, so two equal states always produce byte-identical URLs. Inertia
treats a differing URL as a navigation, so instability there would turn a no-op
into a request.
Ranges are filters[k][from] / filters[k][to]; multi-values are
filters[k][]=a&filters[k][]=b. Brackets stay literal — only keys and values
are percent-encoded.
Sorting is tri-state, with a default underneath
aria-sort reports effectiveSort: the URL’s instructions, or the descriptor’s
defaultSort when the URL pins none. The rows really are ordered by the default,
so announcing “none” would be a lie.
The click cycle reads the URL’s sort instead: unsorted → asc → desc → unsorted.
Cycling from the effective sort would make the first click on the
default-sorted column a no-op. “Unsorted” therefore means “back to
defaultSort” — the §10.2 contract has no way to express “explicitly unordered”.
Column visibility
Precedence is URL > localStorage > descriptor default. A shared link must
show the sender’s columns, not the recipient’s saved preference.
The preference lives under adonia:<panel>:<resource>:columns (§8.1) as a JSON
array of verbatim column keys, written whenever a column is toggled and replayed
as ?columns=a,b,c. The server validates that list against its own allowlist and
returns the columns in declaration order — ?columns= is a set, not a
sequence.
The default visible set is every column whose props.defaultHidden !== true;
the toggle menu lists every column whose props.toggleable !== false.
descriptor.table.columns always carries the full column set, hidden ones
included, so the menu can offer them.
Toggling is a real partial reload, not a CSS change: a hidden C.count column
drops its sub-query server-side. The corollary is that rows only carry keys for
currently visible columns, so every cell tolerates a missing value.
The store is an external store read through useSyncExternalStore, whose server
snapshot is always “no preference”. SSR therefore renders the descriptor default
deterministically and the client swaps in the stored preference after hydration.
Selection and action targets
selection holds page-local ids; selectAllMatching() escalates. That flips
targets from { ids } to { query: state } (§10.3): a bulk action over 40 000
rows ships the query state and the server re-runs the same filtered pipeline,
rather than the client posting 40 000 ids the server would have to authorize one
by one. Running actions is Phase 3 (useAction); this slice owns the payload.
Any URL change clears the selection — the row set moved underneath it, and keeping ids across it would let a bulk action hit rows the user cannot see.
Cells and filters resolve through the registry
Columns resolve against the cell map, filters against the filter map. The
v1 built-ins:
| Map | Types |
|---|---|
cell |
text, badge, boolean, date, number, image, relation, count |
filter |
select-filter, multi-select-filter, ternary-filter, date-range-filter, number-range-filter, relation-filter |
An unregistered type renders <UnknownComponent> with a console warning and
never throws (protocol v1 §8) — a C.custom column or a plugin whose client
half is not installed must not take the index page down.
FilterProps is { node, value, setValue }: filters are fully controlled from
the query state and own no state that could disagree with the address bar. The
.trashed() ternary is wired to ?trashed= rather than filters[trashed],
because it selects a soft-delete scope in the pipeline, not a filter predicate.
relation-filter uses the node’s options capability URL (protocol v1 §4) two
ways: ?q= to search (same 300 ms debounce) and ?values= to hydrate the label
of an already-selected id the current search page does not contain — without the
second, reloading a filtered URL would show a blank select.
Accessibility
Real <table> semantics: scope="col" headers, a <caption> naming the
resource, aria-sort on every sortable header, aria-expanded/aria-controls
on the filter disclosure, and a <details>-based column menu so the disclosure
is keyboard-operable with no focus-trap code. Pagination and sorting are
<button>s, not links, because each one is a partial reload rather than a page
visit.
tests/data_table.test.tsx, tests/cells.test.tsx and tests/filters.test.tsx
each assert axe-clean output over the WCAG 2.0/2.1 A+AA rule sets.
Not yet covered
Cursor pagination (the cursor contract) needs descriptor.table.pagination on the wire
before the table can switch to next/prev-only navigation and single-column sort;
state.cursor is parsed and round-tripped today, but no UI drives it. Row and
bulk actions are Phase 3 (useAction, §13.3).