A TypeScript hook that manages filters, sorts, includes, fields and pagination — and outputs a URL string your Laravel backend understands instantly.
const builder = useQueryBuilder()
builder
.filter('status', 'active')
.filter('salary', '>', 1000)
.sort('created_at', 'desc')
.include('posts', 'comments')
.page(1).limit(20)
builder.build()
// → ?filter[status]=active&filter[salary][gt]=1000
// &sort=-created_at&include=posts,comments&page=1&limit=20
Hydrate the builder from the URL on mount, push changes back as the user filters.
Per-bucket allow/deny lists keep crafted URLs out of your backend.
Map UI names (userName) to API names
(name) once. The hook,
.build() and the URL adapter translate
transparently.
Declare mutually exclusive filters once
(date vs
between_dates). The library removes incompatible
ones automatically when you add a new one.
Beyond equality:
.filter('salary', '>', 1000),
less / greater / distinct, with
FilterOperator constants for
type-safe autocomplete.
nextPage(),
previousPage() and auto page-reset when filters
or limit change — so you never request page 7 of nothing.
Mutations trigger re-renders automatically.
toArray() pairs naturally with TanStack Query
as a queryKey.
Filters in the URL, just like that. Share links, deep-link to state, restore on refresh — no serialization plumbing required.
Share filtered links, deep-link to state, restore on refresh. With aliases,
per-bucket allowlist / denylist, custom adapters for
localStorage & co. Read-only or two-way
binding via history.replaceState /
pushState / your router.
const builder = useQueryBuilder({
adapter: createSearchParamsAdapter({ sync: true }),
})
builder.filter("status", "active").sort("created_at", "desc")
// URL bar: /?filter[status]=active&sort=-created_at
Three steps to your first query.
Install the package
npm install @cgarciagarcia/react-query-builder
Use the hook in your component
import { useQueryBuilder } from "@cgarciagarcia/react-query-builder"
function UserList() {
const builder = useQueryBuilder()
return (
<button onClick={() => builder.filter('status', 'active')}>
Filter active
</button>
)
}
Pass the query string to your API call
fetch('/api/users' + builder.build())
// → GET /api/users?filter[status]=active
All methods are chainable unless noted. The builder re-renders its component on every state change.
All fields are optional. Pass a config object to set initial state, plug an
adapter, or customize behavior. The config is read once on the first render — see
URL Adapter for the
adapter contract in detail.
const builder = useQueryBuilder({
aliases: { frontend_key: 'backend_key' }, // map names transparently
// ── Initial state (all optional) ──────────────────────────────────
filters: [], // pre-set filters
includes: [], // pre-set includes
sorts: [], // pre-set sorts
fields: [], // pre-set fields
params: {}, // extra custom params
pagination: { page: 1, limit: 20 },
// ── Hydrate / persist from an external source ─────────────────────
// The hook calls `adapter.read({ aliases })` once on the first render
// (lazy init, same semantics as `useState(() => …)`). If `write` is
// defined, it's wired as a subscriber and fires on every mutation.
adapter: createSearchParamsAdapter({ sync: true }),
// ── Behavior ──────────────────────────────────────────────────────
pruneConflictingFilters: {
date: ['between_dates'], // mutually exclusive filters
},
delimiters: {
global: ',', // default separator for all groups
fields: null, // override per group (null = use global)
filters: null,
sorts: null,
includes: null,
params: null,
},
useQuestionMark: true, // prepend "?" to build() output
})
Explicit fields always win over what an adapter's
read() returned, so
filters: [...] here overrides whatever the URL had at mount time.
After mount, call builder.rehydrate() to re-pull
state from the adapter — useful when the source changes outside the builder
(browser Back/Forward, another tab, server push).
See Reacting to external changes.
// Simple equality filter
builder.filter('status', 'active')
// → ?filter[status]=active
// Comparison operators: = | < | > | <= | >= | <>
builder.filter('salary', '>', 1000)
builder.filter('age', '<=', 65)
// Append multiple values
builder.filter('tag', 'php').filter('tag', 'laravel')
// → ?filter[tag]=php,laravel
// Override (replace) existing values
builder.filter('status', 'inactive', true)
// Remove & clear
builder.removeFilter('status', 'age')
builder.clearFilters()
// Check existence — returns boolean, NOT chainable
builder.hasFilter('status')
builder.hasFilter('status', 'age') // true only if ALL exist
// FilterOperator constants
import { FilterOperator } from "@cgarciagarcia/react-query-builder"
// .Equals | .LessThan | .GreaterThan | .LessThanOrEqual | .GreaterThanOrEqual | .Distinct
builder.sort('created_at') // asc by default
builder.sort('salary', 'desc')
// → ?sort=created_at,-salary
builder.removeSort('created_at', 'salary')
builder.clearSorts()
builder.hasSort('created_at') // → boolean
builder.fields('id', 'name', 'email', 'user.avatar')
// → ?fields=id,name,email&fields[user]=avatar
builder.removeField('email')
builder.clearFields()
builder.hasField('name') // → boolean
builder.include('posts', 'comments', 'profile.avatar')
// → ?include=posts,comments,profile.avatar
builder.removeInclude('posts')
builder.clearIncludes()
builder.hasInclude('posts') // → boolean
builder.setParam('search', 'john doe')
builder.setParam('ids', [1, 2, 3])
// → ?search=john+doe&ids=1,2,3
builder.removeParam('search')
builder.clearParams()
builder.hasParam('search') // → boolean
Adding or removing filters, or changing limit, auto-resets to page 1.
const builder = useQueryBuilder({
pagination: { page: 1, limit: 20 }
})
builder.page(3) // go to specific page
builder.nextPage() // page + 1
builder.previousPage() // page - 1 (min: 1)
builder.limit(50) // change page size
// Getters — NOT chainable
builder.getCurrentPage() // → number | undefined
builder.getLimit() // → number | undefined
// Build the final query string
builder.build()
// → "?filter[status]=active&sort=-salary&page=1"
// Get state as flat array — useful as React Query queryKey
builder.toArray()
// → ["filter[status]=active", "sort=-salary", "page=1"]
// Inspect state inline without breaking the chain
builder.filter('status', 'active').tap(state => console.log(state)).sort('name')
// Conditionally run logic
builder.when(isAdmin, state => console.log('admin:', state))
When two filters cannot coexist (e.g. date vs between_dates),
declare it once — the conflict is automatically bidirectional.
const builder = useQueryBuilder({
pruneConflictingFilters: {
date: ['between_dates'],
}
})
builder.filter('date', '2024-08-13')
// → ?filter[date]=2024-08-13
builder.filter('between_dates', ['2024-08-06', '2024-08-13'])
// → ?filter[between_dates]=2024-08-06,2024-08-13
// ↑ "date" was automatically removed
Use toArray() as the queryKey so your query
re-fetches whenever the builder state changes.
import { useQueryBuilder } from "@cgarciagarcia/react-query-builder"
import { useQuery } from "@tanstack/react-query"
function UserList() {
const builder = useQueryBuilder({
pagination: { page: 1, limit: 20 },
pruneConflictingFilters: { date: ['between_dates'] },
})
const { data, isLoading } = useQuery({
queryFn: () => fetch('/api/users' + builder.build()).then(r => r.json()),
queryKey: ['users', ...builder.toArray()],
// ↑ re-fetches automatically on every builder change
})
return (
<div>
<button onClick={() => builder.filter('status', 'active')}>Active only</button>
<button onClick={() => builder.sort('created_at', 'desc')}>Newest first</button>
<button onClick={() => builder.nextPage()}>Next page</button>
<button onClick={() => builder.clearFilters()}>Clear</button>
</div>
)
}
Map your frontend keys to backend names and get full autocomplete.
type MyAliases = {
userName: 'name'
userEmail: 'email'
}
const builder = useQueryBuilder<MyAliases>({
aliases: { userName: 'name', userEmail: 'email' }
})
builder.filter('userName', 'John')
// → ?filter[name]=John (mapped automatically)