tanstack-url-state 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 B0redDev
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,61 @@
1
- # Temporary Holding Version
1
+ # tanstack-url-state
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ URL search params as React state for [TanStack Router](https://tanstack.com/router), in the style of [nuqs](https://nuqs.dev). The route's own `validateSearch` is the parser, so every key is typed once, links stay type-safe, and there is no second set of parsers to keep in sync.
4
+
5
+ ```sh
6
+ bun add tanstack-url-state
7
+ ```
8
+
9
+ Peer dependencies: `@tanstack/react-router` ^1.170, `react` ≥ 18, `zod` ^4.
10
+
11
+ ## Declare the params on the route
12
+
13
+ ```tsx
14
+ import { createFileRoute } from "@tanstack/react-router";
15
+ import { searchParams } from "tanstack-url-state";
16
+ import { z } from "zod";
17
+
18
+ export const Route = createFileRoute("/settings")({
19
+ ...searchParams({
20
+ section: z.enum(["models", "services"]).default("models").catch("models"),
21
+ q: z.string().optional().catch(undefined),
22
+ }),
23
+ component: SettingsRoute,
24
+ });
25
+ ```
26
+
27
+ `searchParams` returns `validateSearch` plus a middleware that keeps values equal to their default out of the URL (`/settings`, not `/settings?section=models`). Every key must be optional or have a `.default()`; `.catch()` turns a hand-edited bad value into the fallback instead of an error.
28
+
29
+ ## Read and write
30
+
31
+ ```tsx
32
+ import { useSearchState, useSearchStates } from "tanstack-url-state";
33
+
34
+ function SettingsRoute() {
35
+ const [section, setSection] = useSearchState(Route, "section");
36
+ const [filters, setFilters] = useSearchStates(Route, ["q", "section"]);
37
+
38
+ setSection("services");
39
+ setFilters({ q: "rome" }, { debounce: 300 });
40
+ setFilters((prev) => ({ q: prev.q ? null : "rome" }));
41
+ }
42
+ ```
43
+
44
+ - **Optimistic:** every hook reading a key sees the new value at once, before the URL changes.
45
+ - **Batched:** updates in the same tick (or inside a `debounce` window) become one navigation.
46
+ - **Removal:** `null`, `undefined`, `""` and `[]` remove the key, which then reads as its default.
47
+ - **Setters return a promise** that resolves once the URL holds the update.
48
+
49
+ Options, per hook or per call:
50
+
51
+ | Option | Default | Effect |
52
+ | --- | --- | --- |
53
+ | `history` | `"replace"` | `"push"` adds a history entry: use it for real navigation steps. |
54
+ | `scroll` | `false` | Scroll to the top after the update. |
55
+ | `debounce` | `0` | Milliseconds to wait before writing the URL; the state updates immediately. |
56
+
57
+ `searchDefaults(schema)` returns the defaulted keys of a zod object schema, if you need them elsewhere.
58
+
59
+ ## License
60
+
61
+ MIT
@@ -0,0 +1,3 @@
1
+ export { searchDefaults, searchParams } from "./search-params.js";
2
+ export { useSearchState, useSearchStates } from "./use-search-state.js";
3
+ export type { SearchRoute, SearchStateOptions } from "./use-search-state.js";
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { searchDefaults, searchParams } from "./search-params.js";
2
+ export { useSearchState, useSearchStates } from "./use-search-state.js";
@@ -0,0 +1,14 @@
1
+ import { z } from "zod";
2
+ /** Values a route search schema falls back to when a key is missing from the URL. */
3
+ export declare function searchDefaults(schema: unknown): Record<string, unknown>;
4
+ /**
5
+ * Route search options from a zod shape: `validateSearch` (parsing + types) and a middleware
6
+ * that drops values equal to their default from the URL. Spread into `createFileRoute(...)({ ... })`.
7
+ * Every key must be optional or defaulted. Bad URL values should `.catch()` to a fallback.
8
+ */
9
+ export declare function searchParams<T extends z.core.$ZodLooseShape>(shape: T): {
10
+ validateSearch: z.ZodObject<{ -readonly [P in keyof T]: T[P]; }, z.core.$strip>;
11
+ search: {
12
+ middlewares: import("@tanstack/react-router").SearchMiddleware<z.core.$InferObjectOutput<{ -readonly [P in keyof T]: T[P]; }, {}>>[];
13
+ };
14
+ };
@@ -0,0 +1,30 @@
1
+ import { stripSearchParams } from "@tanstack/react-router";
2
+ import { z } from "zod";
3
+ const defaultsCache = new WeakMap();
4
+ /** Values a route search schema falls back to when a key is missing from the URL. */
5
+ export function searchDefaults(schema) {
6
+ if (!(schema instanceof z.ZodObject)) {
7
+ return {};
8
+ }
9
+ let defaults = defaultsCache.get(schema);
10
+ if (!defaults) {
11
+ // zod 4 `.partial()` keeps `.default()`s, so this yields exactly the defaulted keys.
12
+ defaults = Object.fromEntries(Object.entries(schema.partial().parse({})).filter(([, value]) => value !== undefined));
13
+ defaultsCache.set(schema, defaults);
14
+ }
15
+ return defaults;
16
+ }
17
+ /**
18
+ * Route search options from a zod shape: `validateSearch` (parsing + types) and a middleware
19
+ * that drops values equal to their default from the URL. Spread into `createFileRoute(...)({ ... })`.
20
+ * Every key must be optional or defaulted. Bad URL values should `.catch()` to a fallback.
21
+ */
22
+ export function searchParams(shape) {
23
+ const schema = z.object(shape);
24
+ return {
25
+ validateSearch: schema,
26
+ search: {
27
+ middlewares: [stripSearchParams(searchDefaults(schema))],
28
+ },
29
+ };
30
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * nuqs-style URL state on top of TanStack Router: the route's `validateSearch` is the parser,
3
+ * these hooks are `useQueryState`/`useQueryStates`.
4
+ *
5
+ * - Updates are optimistic: every hook reading a key sees the new value immediately.
6
+ * - Updates in the same tick (or within a `debounce` window) are merged into one navigation.
7
+ * - `null`, `undefined`, `""` and `[]` remove the key, so it reads as the schema default.
8
+ * - Defaults are kept out of the URL by `searchParams()`' middleware.
9
+ */
10
+ /** A file route (`Route`) whose `validateSearch` types the keys. */
11
+ export interface SearchRoute {
12
+ id: string;
13
+ options: {
14
+ validateSearch?: unknown;
15
+ };
16
+ types: {
17
+ fullSearchSchema: object;
18
+ };
19
+ }
20
+ type SearchOf<R extends SearchRoute> = R["types"]["fullSearchSchema"];
21
+ type Patch<S, K extends keyof S> = {
22
+ [P in K]?: S[P] | null;
23
+ };
24
+ export interface SearchStateOptions {
25
+ /** `push` adds a history entry; default `replace`. */
26
+ history?: "push" | "replace";
27
+ /** Scroll to top after the update; default `false`. */
28
+ scroll?: boolean;
29
+ /** Delay the URL write (ms) while still updating state immediately; restarts on each update. */
30
+ debounce?: number;
31
+ }
32
+ /** Several search params as one state object; one navigation per update. */
33
+ export declare function useSearchStates<R extends SearchRoute, const K extends keyof SearchOf<R> & string>(route: R, keys: readonly K[], options?: SearchStateOptions): readonly [Pick<SearchOf<R>, K>, (update: Patch<SearchOf<R>, K> | ((prev: Pick<SearchOf<R>, K>) => Patch<SearchOf<R>, K>), callOptions?: SearchStateOptions) => Promise<void>];
34
+ /** One search param as `[value, setValue]`, like `useState`. */
35
+ export declare function useSearchState<R extends SearchRoute, const K extends keyof SearchOf<R> & string>(route: R, key: K, options?: SearchStateOptions): readonly [Pick<SearchOf<R>, K>[K], (update: SearchOf<R>[K] | null | ((prev: SearchOf<R>[K]) => SearchOf<R>[K] | null), callOptions?: SearchStateOptions) => Promise<void>];
36
+ export {};
@@ -0,0 +1,129 @@
1
+ import { useRouter, useSearch } from "@tanstack/react-router";
2
+ import { useCallback, useMemo, useRef, useSyncExternalStore } from "react";
3
+ import { searchDefaults } from "./search-params.js";
4
+ // One queue per app: search params are global to the URL. Only written by client events, never during SSR.
5
+ const EMPTY = {};
6
+ let pending = EMPTY;
7
+ let queued = null;
8
+ let timer;
9
+ let nextFlush = null;
10
+ let resolveFlush;
11
+ const listeners = new Set();
12
+ const subscribe = (listener) => {
13
+ listeners.add(listener);
14
+ return () => {
15
+ listeners.delete(listener);
16
+ };
17
+ };
18
+ const emit = () => {
19
+ for (const listener of listeners) {
20
+ listener();
21
+ }
22
+ };
23
+ const isEmpty = (value) => value === null || value === undefined || value === "" || (Array.isArray(value) && value.length === 0);
24
+ async function flush() {
25
+ timer = undefined;
26
+ const sent = pending;
27
+ const job = queued;
28
+ const resolve = resolveFlush;
29
+ queued = null;
30
+ nextFlush = null;
31
+ try {
32
+ if (job) {
33
+ const patch = Object.fromEntries(Object.entries(sent).map(([key, value]) => [key, isEmpty(value) ? undefined : value]));
34
+ await job.router.navigate({
35
+ to: ".",
36
+ replace: !job.push,
37
+ resetScroll: job.scroll,
38
+ search: (prev) => ({ ...prev, ...patch }),
39
+ });
40
+ }
41
+ }
42
+ finally {
43
+ // The URL now owns what was sent; keep only values set again while navigating.
44
+ const rest = Object.fromEntries(Object.entries(pending).filter(([key, value]) => sent[key] !== value));
45
+ pending = Object.keys(rest).length > 0 ? rest : EMPTY;
46
+ emit();
47
+ resolve?.();
48
+ }
49
+ }
50
+ function enqueue(router, patch, options) {
51
+ pending = { ...pending, ...patch };
52
+ queued = {
53
+ router,
54
+ push: queued?.push === true || options.history === "push",
55
+ scroll: queued?.scroll === true || options.scroll === true,
56
+ };
57
+ nextFlush ??= new Promise((resolve) => {
58
+ resolveFlush = resolve;
59
+ });
60
+ emit();
61
+ clearTimeout(timer);
62
+ // An immediate update also writes any debounced one still waiting.
63
+ timer = setTimeout(flush, options.debounce ?? 0);
64
+ return nextFlush;
65
+ }
66
+ function pick(source, keys) {
67
+ const out = {};
68
+ for (const key of keys) {
69
+ if (key in source) {
70
+ out[key] = source[key];
71
+ }
72
+ }
73
+ return out;
74
+ }
75
+ /** Pending removals read as the schema default, like a missing URL key. */
76
+ function withDefaults(values, defaults) {
77
+ const out = {};
78
+ for (const [key, value] of Object.entries(values)) {
79
+ out[key] = isEmpty(value) ? defaults[key] : value;
80
+ }
81
+ return out;
82
+ }
83
+ /** Several search params as one state object; one navigation per update. */
84
+ export function useSearchStates(route, keys, options = {}) {
85
+ const router = useRouter();
86
+ const defaults = searchDefaults(route.options.validateSearch);
87
+ // Callers pass inline arrays; key the memos on the names, not the array identity.
88
+ const keysKey = keys.join("\0");
89
+ const stableKeys = useMemo(() => keysKey.split("\0"), [keysKey]);
90
+ // Re-renders only when one of these keys changes in the URL…
91
+ const fromUrl = useSearch({
92
+ strict: false,
93
+ structuralSharing: true,
94
+ select: (search) => pick(search, stableKeys),
95
+ });
96
+ // …or in the pending queue (only the keys that have a pending value).
97
+ const lastPending = useRef(EMPTY);
98
+ const pendingValues = useSyncExternalStore(subscribe, () => {
99
+ const last = lastPending.current;
100
+ const next = pick(pending, stableKeys);
101
+ const names = Object.keys(next);
102
+ const same = names.length === Object.keys(last).length && names.every((key) => key in last && last[key] === next[key]);
103
+ if (!same) {
104
+ lastPending.current = next;
105
+ }
106
+ return lastPending.current;
107
+ }, () => EMPTY);
108
+ const values = useMemo(() => ({ ...fromUrl, ...withDefaults(pendingValues, defaults) }), [fromUrl, pendingValues, defaults]);
109
+ const { history, scroll, debounce } = options;
110
+ const set = useCallback((update, callOptions) => {
111
+ // Functional updates chain on values queued earlier in the same tick.
112
+ const prev = { ...values, ...withDefaults(pick(pending, stableKeys), defaults) };
113
+ return enqueue(router, typeof update === "function" ? update(prev) : update, {
114
+ history,
115
+ scroll,
116
+ debounce,
117
+ ...callOptions,
118
+ });
119
+ }, [router, values, stableKeys, defaults, history, scroll, debounce]);
120
+ return [values, set];
121
+ }
122
+ /** One search param as `[value, setValue]`, like `useState`. */
123
+ export function useSearchState(route, key, options) {
124
+ const [values, setValues] = useSearchStates(route, [key], options);
125
+ const set = useCallback((update, callOptions) => setValues((prev) => ({
126
+ [key]: typeof update === "function" ? update(prev[key]) : update,
127
+ }), callOptions), [key, setValues]);
128
+ return [values[key], set];
129
+ }
package/package.json CHANGED
@@ -1,6 +1,45 @@
1
1
  {
2
2
  "name": "tanstack-url-state",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "nuqs-style URL search state for TanStack Router, typed by each route's validateSearch",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/B0redDev/tanstack-url-state.git"
10
+ },
11
+ "keywords": ["tanstack-router", "search-params", "url-state", "react", "zod"],
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "import": "./dist/index.js"
16
+ }
17
+ },
18
+ "files": ["dist"],
19
+ "sideEffects": false,
20
+ "scripts": {
21
+ "build": "rm -rf dist && tsc -p tsconfig.build.json",
22
+ "typecheck": "tsc --noEmit",
23
+ "test": "bun test",
24
+ "prepublishOnly": "bun run typecheck && bun test && bun run build"
25
+ },
26
+ "peerDependencies": {
27
+ "@tanstack/react-router": "^1.170.0",
28
+ "react": ">=18",
29
+ "zod": "^4.0.0"
30
+ },
31
+ "publishConfig": {
32
+ "access": "public"
33
+ },
34
+ "devDependencies": {
35
+ "@happy-dom/global-registrator": "^20.14.6",
36
+ "@tanstack/react-router": "^1.170.41",
37
+ "@types/bun": "^1.4.2",
38
+ "@types/react": "^19.3.0",
39
+ "@types/react-dom": "^19.3.0",
40
+ "react": "^19.3.0",
41
+ "react-dom": "^19.3.0",
42
+ "typescript": "^7.0.2",
43
+ "zod": "^4.6.5"
44
+ }
45
+ }