Search documentation

Search for a page or heading...

tablecn
0

How the search, filter, sort and page are written to the URL, and how to connect it to your router.

Everything that decides which rows users see lives in the URL:

URL
/orders?q=nguyen&status__eq=paid&amount__between=10,50&sort=-amount,customer&page=2&per_page=50
ParamWritten byMeaning
q=nguyenthe search boxthe search text
status__eq=paidthe filterone param per rule: field__operator=value
sort=-amount,customerheader clickscolumn ids, most important first; - means descending
page=2the paginationthe page, counted from 1
per_page=50the rows-per-page pickerthe page size

Defaults aren't written: page 1, the default page size and the default sort leave no param, so a fresh table has a clean URL. Links can be shared, reloads keep the view, and Back / Forward work.

Connect to your router

An adapter reads and writes the URL through your router. You pass it to the FilterProvider, and the table uses the same one:

AdapterImportUse it for
<NextFilterProvider> or useNextAdapter()@querycn/filter-nextNext.js App Router
useReactRouterAdapter()@querycn/filter-react/react-routerreact-router 6.4+ and 7
useBrowserUrlAdapter()@querycn/filter-reactVite and other apps without a client router
noneState stays in memory, the URL is left alone. Good for dialogs and tests.
createMemoryAdapter("status__eq=paid")@querycn/filter-reactIn memory, with a starting query string

Without a FilterProvider, pass the adapter to useDataTable({ adapter }) instead.

All URL adapters replace the current history entry instead of adding one, so ten sorts don't add ten Back steps. They keep the path, the hash and your other query params.

Back to page 1

Page 4 of the old results means nothing for new ones, so every change that reshapes the results starts again at page 1:

  • Sort, page size or search: the table does it itself.
  • Filter: the filter doesn't know about pages, so tell it with onApply:
Reset the page on apply
<FilterProvider fields={orderFields} adapter={adapter} onApply={() => resetPagePatch()}>

onApply returns params to write together with the filter, so the filter and page change in one URL update. resetPagePatch() returns { page: null } (null removes a param), using your page param name if you renamed it. It also covers Clear filters and the chips' ×.

Table options

Pass these as url to useDataTable:

URL options
import type { TableUrlOptions } from "@querycn/table-react"

const url = {
  params: { sort: "order", page: "p", perPage: "size", search: "search" },
  pageSizes: [25, 50, 100],
  defaultPageSize: 25,
  defaultSorting: [{ id: "createdAt", desc: true }],
} satisfies TableUrlOptions

useDataTable({ data, columns, getRowId, url })

Define it outside your component, like columns. In server mode, pass the same object to useTableQuery and parseTableParams, so they all read the URL alike.

Prop

Type

Already using ?q=?

If your app puts something else in q (a site-wide search box, say), the table would read it as its search. Rename the table's param: url: { params: { search: "search" } }.

A URL is user input, so reading it never throws. Whatever doesn't make sense is dropped:

  • sort columns that don't exist, can't sort or are repeated;
  • a page that isn't a positive whole number (reads as page 1);
  • a per_page outside pageSizes (reads as the default);
  • a q longer than 200 characters (cut);
  • filter rules with an unknown field, an operator the field doesn't offer, or a value of the wrong shape. Each rule is checked on its own, so one bad rule doesn't drop the others.

A page past the last one shows the last one. In server mode, the URL is also rewritten to that page once the backend's total is known.

The filter's params in detail

Each applied rule is one param, field__operator=value:

Filter params
?status__eq=paid&amount__between=10,50&tags__in=urgent,vip&deletedAt__isEmpty
  • Ranges and lists are comma-separated: amount__between=10,50, tags__in=urgent,vip.
  • Operators without a value write only the key: deletedAt__isEmpty.
  • OR adds join=or. AND is the default and adds nothing.
  • Two rules on the same field and operator repeat the key: status__ne=draft&status__ne=void.
  • Field names may contain __ too: the key is split at the last one.
  • A comma inside a list item is written %252C, so it isn't read as a separator.
  • Only what a URL can't hold is escaped: spaces, &, =, #, +, % and non-ASCII. Characters like , : / @ stay readable.

When a filter is applied, every param that belongs to the filter is replaced: join, and any field__… key of a declared field. Other params are left alone.

Reading the filter outside React

The provider and the table do this for you. In a backend, a script or a test, use the functions directly:

Filter params
import { decodeFilters, encodeFilters } from "@querycn/filter-core"

const context = { fields: orderFields }

decodeFilters("status__eq=paid&nope__eq=1&amount__between=1&page=2", context)
// → { join: "and", rules: [{ id: "u0", field: "status", operator: "eq", value: "paid" }] }

encodeFilters(
  { join: "and", rules: [{ id: "a", field: "amount", operator: "between", value: ["10", "50"] }] },
  context
)
// → "amount__between=10,50"

decodeFilters takes a query string (with or without ?) or a URLSearchParams. Decoded rules get ids by position (u0, u1…), so the server and the client decode a URL to the same state. In Next.js, parseFilters from @querycn/filter-next/server does the same with a page's searchParams.

The table's params have the same pair of functions (also exported from @querycn/table-react/server):

Table params
import { decodeTableParams, encodeTableParams } from "@querycn/table-react"

decodeTableParams("sort=-amount,amount,nope&page=0&per_page=7", {
  sortableColumns: ["amount", "customer"],
})
// → { sorting: [{ id: "amount", desc: true }], pagination: { pageIndex: 0, pageSize: 20 }, search: "" }

encodeTableParams({ sorting: [{ id: "amount", desc: true }], pagination: { pageIndex: 1, pageSize: 20 }, search: "" })
// → { sort: "-amount", page: "2", per_page: null, q: null }  (null removes the param)

With your own TanStack table instead of useDataTable, useTableUrlState({ adapter, …options }) gives the URL state as controlled props: sorting, pagination, search and their on…Change handlers.

Advanced

A custom filter URL format

To match a URL scheme you already have, pass a urlFormat to the provider, and the same one to parseFilters or decodeFilters on the server. A format maps one rule to one param and back. This one writes _customer=["like","%acme%"]:

lib/legacy-url-format.ts
import type { UrlFormat } from "@querycn/filter-core"

const toUrlOperator: Record<string, string> = { contains: "like", eq: "eq", in: "in" }
const fromUrlOperator = Object.fromEntries(
  Object.entries(toUrlOperator).map(([ours, theirs]) => [theirs, ours])
)

export const legacyUrlFormat: UrlFormat = {
  encodeRule: ({ field, operator, value }) => [
    `_${field}`,
    JSON.stringify(
      operator === "contains" ? ["like", `%${value}%`] : [toUrlOperator[operator] ?? operator, value]
    ),
  ],
  decodeRule: (key, value) => {
    if (!key.startsWith("_")) return null // not a filter param
    const [operator, raw] = JSON.parse(value) // a throw drops the param
    return {
      field: key.slice(1),
      operator: fromUrlOperator[operator] ?? operator,
      value: operator === "like" ? String(raw).replace(/^%|%$/g, "") : raw,
    }
  },
  joinParam: "_join",
}
  • encodeRule only gets complete rules. value is null for operators without one, and an array for ranges and lists.
  • decodeRule returns null for params that aren't rules, like page. What it returns is checked like any other rule, so raw text is fine: "5" becomes 5 on a number field.
  • OR is written as joinParam=or (default join).

A custom adapter

An adapter is a small object that holds a query string. This one keeps the filter and table state in localStorage instead of the URL:

lib/storage-adapter.ts
import { applyParamChanges, type UrlStateAdapter } from "@querycn/filter-react"

export function createStorageAdapter(key: string): UrlStateAdapter {
  const listeners = new Set<() => void>()
  const read = () => localStorage.getItem(key) ?? ""
  return {
    read,
    write: (changes) => {
      const next = applyParamChanges(read(), changes)
      if (next === null) return // nothing changed
      localStorage.setItem(key, next)
      listeners.forEach((listener) => listener())
    },
    subscribe: (onChange) => {
      // `storage` fires for changes made in other tabs
      const onStorage = (event: StorageEvent) => event.key === key && onChange()
      listeners.add(onChange)
      window.addEventListener("storage", onStorage)
      return () => {
        listeners.delete(onChange)
        window.removeEventListener("storage", onStorage)
      }
    },
    readServer: () => "",
  }
}

Create it once (module scope or useState(() => …)), not on every render. Its methods are called without this.

Prop

Type

Router notes

  • Plain browser adapter: it only sees the URL in the browser, so server rendering and hydration see an empty state, then the real one right after. The Next.js and react-router adapters read the URL through the router, so their server render already has it. Don't use it next to a client-side router: the router's navigations aren't observed.
  • react-router + <ScrollRestoration>: if the URL has a hash, every apply scrolls to its target. Use getKey based on the pathname, or avoid hashes on these pages.
  • react-router + useBlocker: when a blocker stops the navigation, the value just written waits until the URL changes again.
  • Two filters on one page share the query string, so give their fields different names.