Reactive, type-safe query string management built on top of nanostores.
- 🔄 Reactive stores that stay in sync with the URL.
- 🔍 Type-safe parameter definitions with encode/decode.
- đź§Ş Dry-run URL generation via
.dry(no history side effects) — great for link building and router integrations. - 🧩 Works with native
URLSearchParamsor custom libs likeqsorquery-string. - 🪝 Framework-friendly via Nanostores.
- 🔢 Arrays, numbers, dates, and custom types.
- âś… Validation-friendly (zod, arktype, etc.).
# npm
npm install @vp-tw/nanostores-qs @nanostores/react nanostores
# yarn
yarn add @vp-tw/nanostores-qs @nanostores/react nanostores
# pnpm
pnpm install @vp-tw/nanostores-qs @nanostores/react nanostoresimport { useStore } from "@nanostores/react";
import { createQsUtils } from "@vp-tw/nanostores-qs";
const qsUtils = createQsUtils();
const str = qsUtils.createSearchParamStore("str");
function StrInput() {
const value = useStore(str.$value); // string | undefined
return (
<input value={value ?? ""} onChange={(e) => str.update(e.target.value)} />
);
}createQsUtils(options?): factory that exposes reactive URL state and helpers.$search: currentwindow.location.searchstring.$urlSearchParams:URLSearchParamsderived from$search.$qs: parsed query object (string | string[] | undefinedvalues by default).createSearchParamStore(name, config?): single-parameter store.createSearchParamsStore(configs): multi-parameter store.defineSearchParam(config).setEncode(fn): helper to attach anencodefunction.
Create a store for one query parameter. Configure decode/encode and defaults; update mutates history, and .dry returns the next search string without side effects.
// num: number | "" (empty string) — demonstrates custom decode with defaultValue
const num = qsUtils.createSearchParamStore("num", (def) =>
def({ decode: (v) => (!v ? "" : Number(v)), defaultValue: "" }),
);
// Read in React
const value = useStore(num.$value);
// Mutate URL
num.update(42); // push history
num.update(42, { replace: true, keepHash: true });
// Dry-run: just compute next search
const nextSearch = num.update.dry(100); // "?num=100"Notes:
- When a new value equals the default, the parameter is removed from the URL.
- Use
force: trueto bypass equality checks and always write.
Manage multiple query parameters together with ergonomic update and updateAll. Both have .dry counterparts for computing the next search string.
const filters = qsUtils.createSearchParamsStore((def) => ({
search: def({ defaultValue: "" }),
category: def({ isArray: true }),
minPrice: def({ decode: Number }).setEncode(String),
maxPrice: def({ decode: Number }).setEncode(String),
sortBy: def({ defaultValue: "newest" }),
}));
// Mutate URL
filters.update("minPrice", 100);
filters.updateAll({
search: "headphones",
category: ["wireless", "anc"],
minPrice: 100,
maxPrice: 300,
sortBy: "newest",
});
// Dry-run (for links/router)
const preview = filters.updateAll.dry({
...filters.$values.get(),
sortBy: "price_asc",
});
// "?search=headphones&category=wireless&category=anc&minPrice=100&maxPrice=300&sortBy=price_asc"Use .dry to generate the search string, then let your router perform navigation. This keeps router features (navigation blocking, data loaders, transitions, scroll restoration, analytics) intact and avoids conflicts with direct History API calls.
import { Link, useLocation, useNavigate } from "react-router-dom";
const location = useLocation();
const nextSearch = filters.updateAll.dry({
...filters.$values.get(),
sortBy: "price_desc",
});
// 1) Plain anchor href
<a href={`${location.pathname}${nextSearch}`}>Apply filters</a>;
// 2) React Router <Link>
<Link
to={{
pathname: location.pathname,
search: nextSearch,
}}
>
Newest
</Link>;
// 3) React Router navigate()
const navigate = useNavigate();
function onApply() {
navigate({ pathname: location.pathname, search: nextSearch });
}Notes:
- Calling
update/updateAllmutates history directly and may bypass router-level hooks/blockers. - Prefer
.dry+ router navigation when your app relies on router features such as navigation blocking.
-
Integrate with routers using
.dry:- Generate
searchvia.dryand hand it to your router (Link,navigate, etc.). - Preserves router features like navigation blocking, transitions, scroll restoration, analytics, loaders.
- Avoids potential conflicts from calling the History API directly.
- Generate
-
Update correlated params together with
createSearchParamsStore:- Example: when
searchchanges, resetpageto1in a single update to keep state consistent and produce a single history entry.
- Example: when
const qsUtils = createQsUtils();
// Correlated params: search + page
const list = qsUtils.createSearchParamsStore((def) => ({
search: def({ defaultValue: "" }),
page: def({ decode: Number, defaultValue: 1 }).setEncode(String),
}));
// Good: one atomic update (single history entry, consistent UI)
function onSearchChange(term: string) {
list.updateAll({ ...list.$values.get(), search: term, page: 1 });
}
// Bad: two separate single-param updates (can create two entries and transient states)
const searchStore = qsUtils.createSearchParamStore("search", {
defaultValue: "",
});
const pageStore = qsUtils.createSearchParamStore("page", (def) =>
def({ decode: Number, defaultValue: 1 }).setEncode(String),
);
function onSearchChangeBad(term: string) {
searchStore.update(term); // 1st history mutation
pageStore.update(1); // 2nd history mutation, possible transient UI state
}nanostores-qs only mutates the parameter(s) it manages. Options:
replace: usehistory.replaceStateinstead ofpushState.keepHash: keep the currentlocation.hashin the URL.state: custom state passed to the History API.force: bypass equality check and force an update.
If a value equals its defaultValue, the parameter is removed from the URL to keep it clean. Customize equality with isEqual when creating the utils:
const qsUtils = createQsUtils({
isEqual: (a, b) => JSON.stringify(a) === JSON.stringify(b),
});Default isEqual comes from es-toolkit.
You can validate via decode and fall back to defaultValue on failure.
import { z } from "zod";
const SortOptionSchema = z.enum(["newest", "price_asc", "price_desc"]);
type SortOption = z.infer<typeof SortOptionSchema>;
const sort = qsUtils.createSearchParamStore("sort", {
decode: (v) => SortOptionSchema.parse(v),
defaultValue: SortOptionSchema[0],
});
function SortSelector() {
const option = useStore(sort.$value); // SortOption
return (
<select
value={option}
onChange={(e) => sort.update(e.target.value as SortOption)}
>
{SortOptionSchema.options.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
);
}import { parse, stringify } from "qs";
const qsUtils = createQsUtils({
qs: {
parse: (search) => parse(search, { ignoreQueryPrefix: true }),
stringify: (values) => stringify(values),
},
});- When
windowis unavailable, the internal search defaults to an empty string; listeners are not attached. - The utils listen to
popstateand patchpushState/replaceStateto stay reactive with navigation.
pnpm pubCopyright (c) 2025 ViPro [email protected] (http://vdustr.dev)