https://github.com/vp-tw/nanostores-qs
A reactive querystring manager using nanostores
https://github.com/vp-tw/nanostores-qs
nanostores persistent qs querystring search searchparams typescript
Last synced: 2 months ago
JSON representation
A reactive querystring manager using nanostores
- Host: GitHub
- URL: https://github.com/vp-tw/nanostores-qs
- Owner: vp-tw
- License: mit
- Created: 2025-03-01T08:28:34.000Z (over 1 year ago)
- Default Branch: main
- Last Pushed: 2025-10-01T02:21:46.000Z (9 months ago)
- Last Synced: 2025-10-04T11:50:05.292Z (8 months ago)
- Topics: nanostores, persistent, qs, querystring, search, searchparams, typescript
- Language: TypeScript
- Homepage: http://vdustr.dev/nanostores-qs/
- Size: 1.66 MB
- Stars: 13
- Watchers: 1
- Forks: 1
- Open Issues: 1
-
Metadata Files:
- Readme: README.md
- Changelog: CHANGELOG.md
- License: LICENSE
Awesome Lists containing this project
README
# @vp-tw/nanostores-qs
Reactive, type-safe query string management built on top of [nanostores](https://github.com/nanostores/nanostores).
## Why `@vp-tw/nanostores-qs`?
- ๐ 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 `URLSearchParams` or custom libs like [`qs`](https://www.npmjs.com/package/qs) or [`query-string`](https://www.npmjs.com/package/query-string).
- ๐ช Framework-friendly via Nanostores.
- ๐ข Arrays, numbers, dates, and custom types.
- โ
Validation-friendly (zod, arktype, etc.).
## Installation
```bash
# 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 nanostores
```
## Quick Start
```tsx
import { 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 (
str.update(e.target.value)} />
);
}
```
## Core Concepts
- `createQsUtils(options?)`: factory that exposes reactive URL state and helpers.
- `$search`: current `window.location.search` string.
- `$urlSearchParams`: `URLSearchParams` derived from `$search`.
- `$qs`: parsed query object (`string | string[] | undefined` values by default).
- `createSearchParamStore(name, config?)`: single-parameter store.
- `createSearchParamsStore(configs)`: multi-parameter store.
- `defineSearchParam(config).setEncode(fn)`: helper to attach an `encode` function.
## Single-Parameter Store (`createSearchParamStore`)
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.
```tsx
// 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: true` to bypass equality checks and always write.
## Multi-Parameter Store (`createSearchParamsStore`)
Manage multiple query parameters together with ergonomic `update` and `updateAll`. Both have `.dry` counterparts for computing the next search string.
```tsx
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"
```
## Router Integration
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.
```tsx
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
Apply filters;
// 2) React Router
Newest
;
// 3) React Router navigate()
const navigate = useNavigate();
function onApply() {
navigate({ pathname: location.pathname, search: nextSearch });
}
```
Notes:
- Calling `update`/`updateAll` mutates history directly and may bypass router-level hooks/blockers.
- Prefer `.dry` + router navigation when your app relies on router features such as navigation blocking.
## Good Practices
- Integrate with routers using `.dry`:
- Generate `search` via `.dry` and 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.
- Update correlated params together with `createSearchParamsStore`:
- Example: when `search` changes, reset `page` to `1` in a single update to keep state consistent and produce a single history entry.
```tsx
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
}
```
### Update Options
`nanostores-qs` only mutates the parameter(s) it manages. Options:
- `replace`: use `history.replaceState` instead of `pushState`.
- `keepHash`: keep the current `location.hash` in the URL.
- `state`: custom state passed to the History API.
- `force`: bypass equality check and force an update.
## Defaults and Equality
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:
```ts
const qsUtils = createQsUtils({
isEqual: (a, b) => JSON.stringify(a) === JSON.stringify(b),
});
```
Default `isEqual` comes from `es-toolkit`.
## Validation and Custom Types
You can validate via `decode` and fall back to `defaultValue` on failure.
```tsx
import { z } from "zod";
const SortOptionSchema = z.enum(["newest", "price_asc", "price_desc"]);
type SortOption = z.infer;
const sort = qsUtils.createSearchParamStore("sort", {
decode: (v) => SortOptionSchema.parse(v),
defaultValue: SortOptionSchema[0],
});
function SortSelector() {
const option = useStore(sort.$value); // SortOption
return (
sort.update(e.target.value as SortOption)}
>
{SortOptionSchema.options.map((o) => (
{o}
))}
);
}
```
## Using a Custom Query String Library
```ts
import { parse, stringify } from "qs";
const qsUtils = createQsUtils({
qs: {
parse: (search) => parse(search, { ignoreQueryPrefix: true }),
stringify: (values) => stringify(values),
},
});
```
## Routing Notes
- When `window` is unavailable, the internal search defaults to an empty string; listeners are not attached.
- The utils listen to `popstate` and patch `pushState/replaceState` to stay reactive with navigation.
## Release
```bash
pnpm pub
```
## License
[MIT](./LICENSE)
Copyright (c) 2025 ViPro ()