npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@cgarciagarcia/react-query-builder

v1.22.0

Published

React hook to build query strings compatible with spatie/laravel-query-builder

Readme

@cgarciagarcia/react-query-builder

A TypeScript React hook that builds query strings compatible with spatie/laravel-query-builder.

Coverage Status Test CI License: MIT Codacy Badge Downloads


Table of Contents


Installation

# npm
npm install @cgarciagarcia/react-query-builder

# yarn
yarn add @cgarciagarcia/react-query-builder

# pnpm
pnpm add @cgarciagarcia/react-query-builder

Peer dependencies: React 17, 18, or 19.


Quick Start

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

const builder = useQueryBuilder()

builder
  .fields('user.name', 'user.last_name')
  .filter('age', 18)
  .filter('salary', '>', 1000)
  .sort('created_at')
  .sort('age', 'desc')
  .include('posts', 'comments')
  .setParam('external_param', 123)
  .page(1)
  .limit(10)

// Use in fetch
fetch("https://myapi.com/api/users" + builder.build())

// builder.build() returns:
// ?fields[user]=name,last_name&filter[age]=18&filter[salary][gt]=1000&sort=created_at,-age&includes=posts,comments&external_param=123&page=1&limit=10

Configuration

Pass an optional config object to useQueryBuilder to set initial state and customize behavior.

const builder = useQueryBuilder({
  // Map frontend field names to backend names
  aliases: {
    "frontend_name": "backend_name",
  },

  // Pre-set initial state
  filters: [],
  includes: [],
  sorts: [],
  fields: [],
  params: {},

  // Define mutually exclusive filters (see Advanced section)
  pruneConflictingFilters: {},

  // Custom delimiters (default: ',')
  delimiters: {
    global: ',',    // applies to all unless overridden
    fields: null,
    filters: null,
    sorts: null,
    includes: null,
    params: null,
  },

  // Prepend '?' to the output of build()
  useQuestionMark: false,

  // Initial pagination state
  pagination: {
    page: 1,
    limit: 10,
  },
})

API Reference

All methods return the builder instance, so they are chainable.

Filters

// Add a filter (appends values by default)
builder.filter('status', 'active')

// Add a filter with an operator
builder.filter('salary', '>', 1000)
builder.filter('age', '>=', 18)

// Override existing filter values instead of appending
builder.filter('status', 'inactive', true)

// Remove specific filters
builder.removeFilter('status', 'age')

// Remove all filters
builder.clearFilters()

// Check if filters exist
builder.hasFilter('status')           // → boolean
builder.hasFilter('status', 'age')    // → true only if ALL exist

Available operators: =, <, >, <=, >=, <>

You can also import FilterOperator for type-safe operators:

import { FilterOperator } from "@cgarciagarcia/react-query-builder"

builder.filter('salary', FilterOperator.GreaterThan, 1000)

Fields

builder.fields('name', 'email', 'user.avatar')
builder.removeField('email')
builder.clearFields()
builder.hasField('name')   // → boolean

Sorts

builder.sort('created_at')           // default: asc
builder.sort('age', 'desc')
builder.removeSort('created_at', 'age')
builder.clearSorts()
builder.hasSort('created_at')        // → boolean

Includes

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

Params

builder.setParam('custom_key', 'value')
builder.setParam('ids', [1, 2, 3])
builder.removeParam('custom_key')
builder.clearParams()
builder.hasParam('custom_key')       // → boolean

Pagination

const builder = useQueryBuilder({
  pagination: { page: 1, limit: 10 }
})

builder.page(3)           // go to page 3
builder.nextPage()        // page + 1
builder.previousPage()    // page - 1 (stops at 1)
builder.limit(25)         // change page size

builder.getCurrentPage()  // → number | undefined
builder.getLimit()        // → number | undefined

Note: Changing filters, removing filters, or changing the limit automatically resets to page 1.


Utilities

build()

Returns the final query string.

builder.build() // → "?filter[age]=18&sort=created_at"

toArray()

Returns the query state as a flat string array. Useful as a React Query queryKey.

import { useQuery } from "@tanstack/react-query"

const builder = useQueryBuilder()

const { data } = useQuery({
  queryFn: () => getUsers(builder.build()),
  queryKey: ['users', ...builder.toArray()],
})

tap(callback)

Inspect the internal state without interrupting the chain.

builder
  .filter('age', 18)
  .tap((state) => console.log(state))
  .sort('name')

when(condition, callback)

Conditionally execute a callback based on a boolean. The builder is returned regardless.

builder.when(isAdmin, (state) => {
  console.log('Admin state:', state)
})

Advanced: Conflicting Filters

Some filters are mutually exclusive in your backend (e.g. date vs between_dates). Use pruneConflictingFilters to let the library handle this automatically.

const builder = useQueryBuilder({
  pruneConflictingFilters: {
    date: ['between_dates'],
    // 'between_dates': ['date'] is added automatically (bidirectional)
  },
})

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 filter was automatically removed)

The conflict is bidirectional by default — you only need to declare it once. You can also declare both directions explicitly if you prefer.


Hydrating from URL

Say you have a list view with filters, sorts and pagination. 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.

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

The 30-second example

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 the page, share the link — the filters come back. That's it.

What just happened:

  • On mount, the adapter reads the current URL and seeds the builder.
  • sync: true then writes once right after mount to normalise the URL against the final state (so BaseConfig defaults, urlOmit, or a stale link the user landed on get reconciled immediately — no mutation required), and on every subsequent change.
  • The URL output mirrors what .build() produces, so your backend and your URL bar agree.

Read-only hydration (no URL writes)

If you only want to hydrate at mount and never touch the URL after, just leave sync out:

useQueryBuilder({
  adapter: createSearchParamsAdapter(),
});

Now read() runs once when the hook mounts — same semantics as useState(() => …) — and that's the end of it. URL changes after mount don't re-hydrate.

Customising the writer

sync accepts three forms depending on how aggressive you want the URL updates to be:

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

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

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

Mount-time normalisation runs in all three modes — the writer fires once right after the builder is constructed so the URL bar reflects the final state immediately. For sync: "push" the very first call still uses replaceState (so the mount fire doesn't add a phantom back-button entry); your custom callback also fires once on mount, guard it with a closure flag if that surprises your router.

Don't worry about other apps' query params — 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 (builder.rehydrate())

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.

Aliases: keep your frontend names, ship backend names

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 handles the translation in both directions automatically:

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

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

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

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

The adapter automatically reads aliases from the builder config, 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]=…). Or your code uses rol in Spanish but the URL should read type for shareable links.

Pass urlAliases to the adapter independently of the builder's aliases:

useQueryBuilder({
  aliases:    { dni: "code", rol: "type" },        // state → backend (drives .build())
  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:

| Layer | Name | Controlled by | |---|---|---| | State (your code) | dni, rol | how you call .filter() | | URL bar | documento, type | adapter.urlAliases | | Backend | code, type | builder.aliases |

Omit urlAliases to make the URL share the builder's names (the common case). Pass urlAliases: {} to explicitly 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: urlOmit

Sometimes the builder carries entries your backend needs but your user shouldn't see in the URL bar. Typical case: default includes/fields the API always requires (include=organization,permissions) that just clutter shareable links.

urlOmit is a writer-only denylist per bucket. The listed names stay in state (so .build() still emits them to the API), they just don't reach the URL:

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
// URL bar  → ?filter[status]=active                                    ← user

Wildcard "*" drops every entry in that bucket — useful for fields, which are an internal API optimisation the user rarely needs to see in the URL:

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

IDE autocomplete suggests "*" thanks to a literal-union typing trick, so it's discoverable while still accepting any plain attribute name.

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

Note: urlOmit only affects the writer. 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 the URL keys

Sometimes the default URL is verbose:

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

That's a mouthful — long enough to overflow in chat previews, ugly to share, and harder to scan. Remap the keys to shorten it:

createSearchParamsAdapter({
  keys: { filter: "filt", sort: "srt", include: "inc", fields: "fld" },
  sync: true,
});

Now the same state produces:

?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, big difference when you have many filters.
  • Cleaner URL bar — easier on the eye for users and for screenshots in support tickets.
  • Brand or domain language — your app says "query", not "filter"? Match the vocabulary your users already know.
  • Avoiding collisions — if the surrounding app already uses ?filter=... or ?sort=... for something else, rename to coexist.

Both reader and writer use the new keys, so round-trips still work. This only affects the URL bar — .build() (what you pass to fetch) keeps using the canonical filter/sort/… names your backend expects.

Locking down which keys can come from the URL

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. You have two primitives, per bucket, and you pick the one that matches your situation:

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 not here is dropped
  },
});

Defaults when you omit a bucket:

  • filters / sorts / includes / fields → allow everything (so the URL can drive filtering freely)
  • params → allow nothing (the catch-all is deny-by-default — params are arbitrary, so you must opt-in by listing them)

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 → I accept any filter from the URL.
  // I have 47 legitimate filter attributes and I add more every month;
  // maintaining a full whitelist is brittle. But is_admin and password
  // must NEVER come through the URL.
  excludeKeys: {
    filters: ["is_admin", "password"],
  },
});

Which one should I use?

| Situation | Use | |---|---| | "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) |

If you set allowed.filters: ["status", "role"], listing is_admin in excludeKeys.filters is redundant — it's already dropped because it's not allowed. It doesn't break anything (and reads as documentation of intent), but it's not pulling weight in runtime. The combination is useful when you keep a stable allowlist and want a separate, fast-changing denylist on top.

Details that apply to both

  • 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).

  • 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:

    aliases: { userName: "name" },
    allowed: { filters: ["userName"] }   // ✓ allows ?filter[name]=...
    // — or —
    allowed: { filters: ["name"] }       // ✓ also allows it

    Same on the deny side: excludeKeys: { filters: ["is_admin"] } blocks both ?filter[is_admin]=... AND ?filter[adminFlag]=... (the alias key), so an attacker can't bypass the denylist by switching vocabularies.

  • For fields, you can match by short prop (password) or by entity.prop (user.password). Pick the precision you need.

  • page and limit are auto-hydrated when present in the URL and emitted by the writer when set in state.pagination. Round-trips out of the box — no extra config.

  • Why per bucket? password is dangerous as a filter but fine as a fields selection (it's just picking which column the API returns). One flat list would force you into all-or-nothing.

Want a different source? Write your own adapter

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.

Going lower-level

If you only want the URL parser 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.

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:

| Character | Why it breaks | |---|---| | , | Multi-value separator — filter[tag]=a,b becomes ["a", "b"] on parse | | <, >, <=, >=, <> (as a leading prefix) | 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 characters 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().


Support

Have a question or need help? Open a discussion on GitHub.


Consider Supporting

If this package helps you, consider supporting its creator:

PayPal: @carlosgarciadev


License

The MIT License (MIT). See LICENSE for more information.