Compatible with spatie/laravel-query-builder

Build Laravel query strings straight from React

A TypeScript hook that manages filters, sorts, includes, fields and pagination — and outputs a URL string your Laravel backend understands instantly.

$ npm install @cgarciagarcia/react-query-builder
Downloads MIT React TypeScript
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

Sync with the URL

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.

Frontend ↔ Backend aliases

Map UI names (userName) to API names (name) once. The hook, .build() and the URL adapter translate transparently.

Conflict-aware filters

Declare mutually exclusive filters once (date vs between_dates). The library removes incompatible ones automatically when you add a new one.

Comparison operators

Beyond equality: .filter('salary', '>', 1000), less / greater / distinct, with FilterOperator constants for type-safe autocomplete.

Smart pagination

nextPage(), previousPage() and auto page-reset when filters or limit change — so you never request page 7 of nothing.

React-aware re-renders

Mutations trigger re-renders automatically. toArray() pairs naturally with TanStack Query as a queryKey.

Just shipped

What's new? URL state sync.

Filters in the URL, just like that. Share links, deep-link to state, restore on refresh — no serialization plumbing required.

Hydrate the builder from the URL

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
Getting started

Quick Start

Three steps to your first query.

1

Install the package

npm install @cgarciagarcia/react-query-builder
2

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>
  )
}
3

Pass the query string to your API call

fetch('/api/users' + builder.build())
// → GET /api/users?filter[status]=active
Reference

API Reference

All methods are chainable unless noted. The builder re-renders its component on every state change.

Configuration #

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.

Filters #

// 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

Sorts #

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

Fields #

builder.fields('id', 'name', 'email', 'user.avatar')
// → ?fields=id,name,email&fields[user]=avatar

builder.removeField('email')
builder.clearFields()
builder.hasField('name')             // → boolean

Includes #

builder.include('posts', 'comments', 'profile.avatar')
// → ?include=posts,comments,profile.avatar

builder.removeInclude('posts')
builder.clearIncludes()
builder.hasInclude('posts')          // → boolean

Custom Params #

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

Pagination #

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

Utilities #

// 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))

Conflicting Filters #

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
Integrations

Works great with TanStack Query

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>
  )
}

Typed Aliases

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)