querypipe
Recipes

TanStack Table + nuqs

Manual wiring — querypipe owns the contract, nuqs owns URL state, TanStack renders.

This recipe mirrors examples/react-table in the repository. There is deliberately no toTanStackState adapter here — that ships in v0.2 — so you can see the exact seams the adapter will replace.

The division of labor:

  • querypipe owns the query contract (the spec and its canonical string)
  • nuqs owns URL state (the ?q= param stores the canonical string)
  • TanStack Table renders (with manualSorting / manualFiltering — it never sorts)

1. The contract

import { createQuery, enumField, isOk, numberField, stringField } from "querypipe";

const products = createQuery({
  fields: {
    id: numberField(),
    name: stringField(),
    price: numberField(),
    stock: numberField(),
    category: enumField(["phone", "laptop", "tablet"]),
  },
  stableBy: "id",
  guards: { maxSortColumns: 3 },
});

2. URL state: the URL stores the canonical string

nuqs custom parsers are two functions — exactly querypipe's two boundaries:

import { createParser, useQueryState } from "nuqs";

const querySpecParser = createParser({
  parse(value: string) {
    const result = products.parse(value);
    return isOk(result) ? result.value : null; // invalid URLs fall back to default
  },
  serialize(spec) {
    const result = products.withSpec(spec);
    return isOk(result) ? result.value.stringify() : "";
  },
});

const DEFAULT_SPEC = products.sort("price", "desc").toSpec();

// in the component:
const [spec, setSpec] = useQueryState("q", querySpecParser.withDefault(DEFAULT_SPEC));

Because stringify is canonical and byte-stable, the same query always produces the same URL — shareable, cacheable, diff-able.

3. Progressive sorting from header clicks

Click = sort (reset chain) · shift+click = thenSort (refine) · click on an already-sorted column = toggleSortDirection:

import type { QuerySpec } from "querypipe";

type SortableProductField = "id" | "name" | "price" | "stock" | "category";

declare const spec: QuerySpec;
declare function setSpec(next: QuerySpec): void;

function onHeaderClick(field: SortableProductField, event: { shiftKey: boolean }) {
  const restored = products.withSpec(spec);
  const query = isOk(restored) ? restored.value : products;
  const existing = spec.sorts.some((s) => s.field === field);

  const next = existing
    ? query.toggleSortDirection(field) // same column: flip direction, keep chain position
    : event.shiftKey
      ? query.thenSort(field)          // refine: append with next priority
      : query.sort(field);             // reset: this column becomes priority 1

  setSpec(next.toSpec());
}

4. Execution and rendering

const rows = React.useMemo(() => query.apply(DATA), [query]); // non-mutating, tie-broken by id

const table = useReactTable({
  data: rows,
  columns,
  getCoreRowModel: getCoreRowModel(),
  manualSorting: true,   // querypipe owns sorting
  manualFiltering: true, // and filtering — TanStack only renders
});

Header cells read the badge straight from the spec — ▲2 is { direction: "asc", priority: 2 }, no separate UI state to reconcile.

The complete runnable app (Vite + React 18) is in the repository: examples/react-table.

On this page