URL state sync

Hydrate the builder from the URL

Share filtered links, deep-link to state, restore on refresh. All without writing serialization or hydration logic.

import {
  useQueryBuilder,
  createSearchParamsAdapter,
} from "@cgarciagarcia/react-query-builder"

const builder = useQueryBuilder({
  adapter: createSearchParamsAdapter({ sync: true }),
})

builder.filter("status", "active").sort("created_at", "desc")
// URL bar is now: /?filter[status]=active&sort=-created_at
// Refresh, share, deep-link — the filters come back.

What it solves #

A user picks status = active, sorts by date, opens page 3 — and shares the link with a teammate. You want that link to open with the exact same filters already applied. No bespoke serialization, no manual re-hydration.

That's what an adapter does: bridges the builder to an external source (URL, localStorage, anything). The built-in createSearchParamsAdapter handles the URL case.

30-second example #

Pass an adapter, set sync: true, and you're done.

import {
  useQueryBuilder,
  createSearchParamsAdapter,
} from "@cgarciagarcia/react-query-builder"

const builder = useQueryBuilder({
  adapter: createSearchParamsAdapter({ sync: true }),
})

builder.filter("status", "active").sort("created_at", "desc")
// URL bar:  /?filter[status]=active&sort=-created_at

What just happened:

  • On mount, the adapter read the URL and seeded the builder.
  • sync: true then writes once right after mount to normalise the URL against the final state (defaults, urlOmit, a stale link — no mutation required), and on every subsequent change.
  • The URL output mirrors what .build() emits, so backend and URL bar agree.

Read-only mode #

Only want to hydrate on mount and never touch the URL after? Just leave sync out.

useQueryBuilder({
  adapter: createSearchParamsAdapter(),
})
// `read()` runs once at mount — same semantics as `useState(() => …)`.
// URL changes after mount do NOT re-hydrate.

Two-way binding #

sync accepts three forms depending on how aggressive the URL updates should be.

// 1) Default: replaceState — no extra history entries
createSearchParamsAdapter({ sync: true })           // alias for "replace"

// 2) pushState — every change is a back-button step
createSearchParamsAdapter({ sync: "push" })

// 3) Bring your own — Next.js / React Router / debouncing
createSearchParamsAdapter({
  sync: (search) => router.replace({ search }),
})

Mount-time normalisation

The writer fires once right after the builder is constructed in all three modes, so the URL bar lands on the final state immediately — no mutation needed for urlOmit, defaults, or stale links to take effect. For sync: "push" the very first call still uses replaceState (so this mount fire doesn't add a phantom back-button entry). Custom callbacks also fire on mount — guard with a closure flag if your router would loop on a redirect.

Other apps' query params are safe

The writer preserves anything it doesn't recognise. So ?utm_source=newsletter or ?theme=dark stays untouched while your filters get added, updated, or cleared.

Reacting to external changes #

The adapter read() runs once at mount. After that, the builder owns the state. If something else changes the underlying source — the user clicks Back/Forward, another tab mutates localStorage, a websocket pushes a new server-side filter — the builder won't notice on its own.

builder.rehydrate() re-runs the adapter's read() and replaces the data layer (filters / sorts / includes / fields / params / pagination) with whatever the source now says. Config — aliases, delimiters, pruneConflictingFilters, useQuestionMark — is preserved.

const builder = useQueryBuilder({
  adapter: createSearchParamsAdapter({ sync: true }),
})

// Re-pull state from the URL after the browser navigates.
useEffect(() => {
  const onPop = () => builder.rehydrate()
  window.addEventListener("popstate", onPop)
  return () => window.removeEventListener("popstate", onPop)
}, [builder])

Why manual and not automatic? Different sources need different listeners (popstate for history, storage for cross-tab localStorage, a websocket subscription for server state, …). Wiring the right listener belongs in your code, where the choice is obvious. The library stays out of your way.

Other sources

Same pattern works for any custom adapter. A localStorage adapter pairs with window.addEventListener("storage", …) to react to other tabs. A server-backed adapter pairs with whatever push mechanism you already use.

Aliases #

You probably name things one way in the UI (userName, createdAt) and another in the API (name, created_at). Pass aliases to the builder and the adapter translates in both directions automatically.

useQueryBuilder({
  aliases: { userName: "name", createdAt: "created_at" },
  adapter: createSearchParamsAdapter({ sync: true }),
})

// Your code uses frontend names:
builder.filter("userName", "Jane").sort("createdAt", "desc")

// URL bar shows backend names (same as .build()):
//   /?filter[name]=Jane&sort=-created_at

// On refresh, ?filter[name]=Jane hydrates as { userName: "Jane" }.

The adapter reads the aliases from the builder config automatically, so you only declare them once.

Different names for URL and backend

The URL and the backend don't have to share the same naming. Maybe you want the URL to speak the user's language (?filter[documento]=…) while your API keeps technical names (?filter[code]=…). Pass urlAliases to the adapter independently of the builder's aliases:

useQueryBuilder({
  aliases:    { dni: "code", rol: "type" },         // state → backend
  adapter: createSearchParamsAdapter({
    sync: true,
    urlAliases: { dni: "documento", rol: "type" }, // state → URL bar
  }),
})

builder.filter("dni", "12345").filter("rol", "admin")
// URL bar:  ?filter[documento]=12345&filter[type]=admin   ← user sees this
// .build(): ?filter[code]=12345&filter[type]=admin        ← API receives this

Three independent namespaces for the same logical attribute:

LayerNameControlled by
State (your code)dni, rolhow you call .filter()
URL bardocumento, typeadapter.urlAliases
Backendcode, typebuilder.aliases

Omit urlAliases to make the URL share the builder's names (the common case). Pass urlAliases: {} to opt out of any URL translation — the URL then mirrors your state-space names verbatim, even when builder.aliases is set.

Security note

With URL ≠ backend, an attacker who knows the backend name could craft ?filter[code]=… directly. Lock it down with an allowlist on the adapter:

adapter: createSearchParamsAdapter({
  sync: true,
  urlAliases: { dni: "documento", rol: "type" },
  allowed: { filters: ["documento", "type"] },   // backend-name attempts → dropped
})

Hide noisy state from the URL #

Sometimes the builder carries entries your backend needs but the user shouldn't see in the URL bar — typical case: default includes the API always requires (organization, permissions) that just clutter shareable links. urlOmit is a writer-only denylist per bucket:

useQueryBuilder({
  includes: ["organization", "permissions"],   // always sent to API
  adapter: createSearchParamsAdapter({
    sync: true,
    urlOmit: { includes: ["organization", "permissions"] },
  }),
})

builder.filter("status", "active")
// .build() → ?filter[status]=active&include=organization,permissions   ← backend gets them
// URL bar   → ?filter[status]=active                                    ← user sees only this

Alias-aware on filters and sorts — list a name in either vocabulary (state or URL) and both forms are skipped.

Wildcard "*" drops every entry in that bucket. Most common use: hide all fields from the URL, since they're an internal API optimisation the user rarely cares about. IDE autocomplete surfaces "*" via a literal-union typing trick.

adapter: createSearchParamsAdapter({
  sync: true,
  urlOmit: {
    fields:   ["*"],                              // hide ALL fields
    includes: ["organization", "permissions"],    // hide only these
  },
})

Reader is not affected

urlOmit only skips on write. If a crafted URL contains one of these names, the reader still processes it. Add the same names to excludeKeys if you also want to refuse them on hydration.

Renaming URL keys #

The default URL can get long fast:

?filter[status]=active&filter[role]=admin&sort=-created_at&include=author,tags&fields[user]=id,name

Remap the keys to shorten it:

createSearchParamsAdapter({
  keys: { filter: "filt", sort: "srt", include: "inc", fields: "fld" },
  sync: true,
})
// → ?filt[status]=active&filt[role]=admin&srt=-created_at&inc=author,tags&fld[user]=id,name

Why you might want this:

  • Shorter, more shareable links — saves bytes per param.
  • Cleaner URL bar — easier on the eye for users and screenshots.
  • Brand / domain language — your app says "query", not "filter".
  • Collision avoidance — the surrounding app already uses ?filter=… for something else.

Reader and writer use the same keys, so round-trips still work. This only affects the URL bar — .build() (what you pass to fetch) keeps using the canonical names your backend expects.

Locking it down #

The URL is user input. Without limits, a crafted link like ?filter[is_admin]=true would flow straight into your .build() call and out to your backend. Two primitives, per bucket, cover the two opposite needs.

allowed — strict allowlist ("only these")

Use it when you have a short, explicit list of what's legitimate.

createSearchParamsAdapter({
  allowed: {
    filters: ["status", "role", "created_at"],
    sorts:   ["created_at", "name"],
    params:  ["locale", "tenant"],   // anything else is dropped
  },
})

Defaults when you omit a bucket:

  • filters / sorts / includes / fieldsallow everything (so URL-driven filtering keeps working).
  • paramsdeny everything (the catch-all is deny-by-default; you must opt in by listing keys).

excludeKeys — targeted denylist ("everything except these")

Use it when you trust the bucket in general but want to block a handful of dangerous names — without enumerating everything legitimate.

createSearchParamsAdapter({
  // No allowed.filters → any filter from the URL is accepted.
  // I have 47 legitimate filter attributes and add more every month;
  // maintaining a whitelist is brittle. But is_admin and password
  // must NEVER come through the URL.
  excludeKeys: {
    filters: ["is_admin", "password"],
  },
})

Which one should I use?

SituationUse
"Short, explicit list of what's allowed"allowed
"Accept everything, except these few"excludeKeys
"Whitelist plus a moving denylist on top"both — defense in depth

Alias-aware on filters and sorts

When you set aliases, the policy matches if either the URL-side name (backend) OR the state-side name (frontend) is in the list. Write your rules in whichever vocabulary you think in — and listing one form on the deny side blocks both, so an attacker can't bypass by switching vocabularies.

A few more details:

  • Both apply to the reader and the writer symmetrically — the URL bar is always inside your policy.
  • excludeKeys always wins over allowed (deny beats allow).
  • For fields, match by short prop (password) or by entity.prop (user.password). Pick the precision you need.
  • Why per bucket? password is dangerous as a filter but fine as a fields selection (just picking which column the API returns). One flat list would force you into all-or-nothing.

Custom adapters #

QueryBuilderAdapter is just { read, write? }. Wrap anything:

// Persist to localStorage instead of the URL
const localStorageAdapter: QueryBuilderAdapter = {
  read:  () => JSON.parse(localStorage.getItem("filters") ?? "{}"),
  write: (state) => localStorage.setItem("filters", JSON.stringify(state)),
}

useQueryBuilder({ adapter: localStorageAdapter })

Same pattern works for react-router search params, hash routers, in-memory stores, IndexedDB — anything you can read from and write to.

Low-level utilities #

If you only want the URL parser/serializer without the hook integration, both pieces are exported as pure functions.

import {
  parseSearchParams,
  serializeSearchParams,
} from "@cgarciagarcia/react-query-builder"

// URL → state
parseSearchParams("?filter[status]=active&sort=-name")
// → { filters: [...], sorts: [{ attribute: "name", direction: "desc" }] }

// state → URL
serializeSearchParams({
  filters: [{ attribute: "status", value: ["active"] }],
})
// → "filter[status]=active"

Both accept the same keys / urlAliases / allowed / excludeKeys options as the adapter, plus an optional pre-compiled PolicyGate as the third argument for the very perf-conscious.

Known limitations #

The URL protocol uses a few characters as control symbols. Filter values containing them silently corrupt on round-trip — the writer emits the value, but the reader splits it differently. Until we lift this limitation, treat these as off-limits inside filter values:

CharacterWhy it breaks
,Multi-value separator — filter[tag]=a,b becomes ["a", "b"] on parse.
<, >, <=, >=, <> (leading)Operator syntax — filter[age]=>=18 becomes operator >= + value ["18"].

Other "special-looking" characters like %, &, + and spaces are fine — they travel URL-escaped and survive the round-trip intact.

If you need any of these inside a value (e.g. tags with commas), keep that piece of state outside the URL adapter, or escape it on your side before passing it to .filter().

Try it live

The playground has dedicated examples for the URL adapter, two-way sync, and a localStorage adapter.

Open Playground