Skip to main content

nuqs-table-url-state

nuqs-table-url-state documents @cogs/nuqs's createTableUrlState factory, which wires shareable, bookmarkable pagination, filters, sort, and search into a TanStack-Table-backed list view from a single config object per screen — instead of hand-rolling a use*Filters.ts hook for every table. It builds directly on the generic nuqs foundation (parsers, useQueryState, hydration) covered by the sibling nuqs-url-state skill, and is scoped specifically to the table/filter/pagination layer on top of it.

What it does

createTableUrlState takes one config object — filters (built with buildFilterParsers), sort, search, and pageSize — and returns a useTableUrlState hook plus buildUrl/searchParamsAPI/serializer for building links outside React. The hook exposes pagination, filters, sorting, searchQuery, and a derived isAnyFilterActive boolean, alongside setters (setFilters, setSorting, setSearchQuery, setPagination) and both a combined reset (resetFiltersAndSearch) and split resets (resetFilters, resetSearch).

Two structural rules are baked in rather than left to each screen to remember:

  • Every filter, sort, or search change resets pagination to page 0. setFilters, setSorting, and setSearchQuery all reset pagination.page as part of the same state update — not optional or configurable.
  • Page indices convert automatically. pagination.page is 0-indexed internally (matching TanStack Table's PaginationState exactly, so there's no adapter layer), while the URL is 1-indexed for human-friendly links. The pageIndexParserStrict parser handles the +1/-1 conversion at the URL parse/serialize boundary — you never write it yourself.

It also ships buildFilterParsers (turns a list of filter-field descriptors into faceted-array or plain-string parsers automatically) and getColumnsSortParser (encodes ColumnSort[] as col-dir,col-dir URL tokens, with optional validation of sort tokens against a sample row shape), plus a stateful mock under @cogs/nuqs/testing for tests that exercise this wiring without hitting real browser URL APIs.

Why it was created

The skill's own metadata records a concrete motivating survey rather than a general design preference: a review of envmgr-ui, a table-heavy Next.js app, found ~20 near-identical use*Filters.ts hooksuseBestPracticesFilters.ts, usePodRestartFilters.ts, useHealthCheckFilters.ts, useDeploymentHistoryFilters.ts, useDnsHealthFilters.ts, and roughly fifteen more — each implementing the same ~150-line template (useQueryState per scalar field, batched useQueryStates for pagination and filters, { shallow: true } re-applied by hand, and four callbacks that all called setPage(0, ...)) with zero behavioral variance between them. The commit history confirms this: @cogs/nuqs was added in a single commit, feat(nuqs): add @cogs/nuqs, ported from envmgr-ui's table URL-state patterns, which describes extracting the page-index conversion, the always-reset factory, the parser builders, and the stateful test mock directly from that survey. envmgr-ui itself was not modified — this package is a clean-room extraction of the pattern, not a live migration.

The always-reset-page-to-0 rule specifically is justified the same way: it was found with zero exceptions across the ~20 surveyed hooks, so the factory makes it structural (impossible to forget) rather than something each new hook has to re-implement correctly.

There is no dedicated founding dossier narrating why this table factory was built, so the provenance above comes from the skill's own metadata.json abstract and its two git commits, not a retrospective writeup.

How it works

  • Config vs. hook-call options are deliberately split. filters, sort, search, and pageSize are evaluated once per screen in createTableUrlState, outside any component. startTransition, by contrast, is a useTableUrlState(options) argument, not a factory-config field — because it can only come from useTransition() inside a component, which doesn't exist yet at factory-definition time. When provided, it's threaded uniformly into every parser's withOptions() internally, resolving a divergence where roughly half the surveyed hooks applied startTransition per-field and half omitted it for no discernible reason.
  • filters only accepts array-valued (faceted) parsers. buildFilterParsers also supports plain-string parsers for free-text fields, but createTableUrlState itself only takes the faceted, array-valued ones — every hook in the original survey routed its filter-reset cascade through array-valued filters only. A standalone text filter should be wired as its own useQueryState call per the base nuqs-url-state skill, not forced through this config.
  • Both reset shapes are always exposed, not chosen. Most surveyed hooks only had one resetFiltersAndSearch; one (useDnsHealthFilters.ts) diverged and exposed split resetFilters/resetSearch for a "clear search but keep filters" affordance. Rather than picking one shape, the factory exposes both, and each screen picks whichever matches its actual UI.
  • The test mock round-trips real parse/serialize logic, not raw values. @cogs/nuqs/testing re-exports the real parsers (parseAsString, pageIndexParserStrict, etc.) verbatim from nuqs/server — they're pure functions — and only fakes useQueryState, useQueryStates, and NuqsAdapter. That matters specifically for pageIndexParserStrict, where the internal (0-indexed) state and serialized URL (1-indexed) are deliberately different values: a mock that just stores whatever it's given would let a test assert an incorrect index relationship and still pass. The mock also respects clearOnDefault, so a param correctly disappears from the URL when reset to its default instead of lingering as an explicit value.
  • No import-time side effects. In the surveyed app, two diagnostics tables forked a local copy of the shared page-index parser specifically to dodge an import graph that triggered unrelated navigation-config initialization and broke test isolation. @cogs/nuqs modules do nothing at import time beyond defining parsers/constants/hooks, so that "local copy to avoid coupling" workaround shouldn't be necessary when adopting this package.

When to use it / when not to

Reach for createTableUrlState when building or reviewing a TanStack-Table-backed list view that needs URL-shareable pagination plus any combination of filters, sort, or search — especially if you're about to write a new use*Filters.ts-style hook, or you're debugging a table where changing a filter doesn't reset the page back to the first one.

It's the wrong tool when:

  • You need a single standalone query param outside a table context — use the generic nuqs-url-state skill's parser/useQueryState guidance instead.
  • You need a plain-text filter field alongside table state — wire it as its own useQueryState call rather than forcing it through createTableUrlState's filters config, which only accepts faceted array parsers.
  • You're mocking nuqs in tests without going through @cogs/nuqs/testing — a hand-rolled no-op mock can hide real broken URL wiring, since assertions that don't depend on the setter's effect will pass even if the component never calls it correctly.