---
title: "The index table"
image: "https://adonia.pages.dev/og.png"
version: "next"
---

> Documentation Index
> Fetch the complete documentation index at: https://adonia.pages.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# The index table

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](/query-pipeline) (§8.3) and
[table columns](/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:

<!-- @sample-preamble
import { useTableState } from '@adonia/ui'
import type { ResourceDescriptor } from '@adonia/ui'
declare const descriptor: ResourceDescriptor
-->

```tsx
const table = useTableState(descriptor)

table.state // { search, filters, sort, page, perPage, cursor, columns, trashed }
table.setSort('publishedAt') // → /admin/posts?sort=publishedAt
```

That 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:

<!-- @sample-preamble
import { router } from '@inertiajs/react'
declare const url: string
-->

```ts
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).

Source: https://adonia.pages.dev/index-table/index.mdx
