Skip to content

Commit 79f80da

Browse files
authored
feat: Allow remapping keys in useQueryStates (#671)
* feat: Allow remapping keys in useQueryStates While it was possible to do it via speading & remapping variable names in userland, this makes it more declarative and should help decouple business/domain logic in variable names vs short URL search param keys. * chore: Fix useEffect loop due to referential inconsistency of default value * chore: Fix dependency arrays * doc: Adding docs on key remapping in useQueryStates
1 parent c4891be commit 79f80da

File tree

5 files changed

+264
-42
lines changed

5 files changed

+264
-42
lines changed

Diff for: packages/docs/content/docs/batching.mdx

+33
Original file line numberDiff line numberDiff line change
@@ -111,3 +111,36 @@ This will clear `lat` & `lng`, and leave other search params untouched.
111111

112112
</Callout>
113113

114+
### Shorter search params keys
115+
116+
One issue with tying the parsers object keys to the search params keys was that
117+
you had to trade-off between variable names that make sense for your domain
118+
or business logic, and short, URL-friendly keys.
119+
120+
In `nuqs@1.20.0` and later, you can use a `urlKeys` object in the hook options
121+
to remap the variable names to shorter keys:
122+
123+
```ts
124+
const [{ latitude, longitude }, setCoordinates] = useQueryStates(
125+
{
126+
// Use variable names that make sense in your codebase
127+
latitude: parseAsFloat.withDefault(45.18),
128+
longitude: parseAsFloat.withDefault(5.72)
129+
},
130+
{
131+
urlKeys: {
132+
// And remap them to shorter keys in the URL
133+
latitude: 'lat',
134+
longitude: 'lng'
135+
}
136+
}
137+
)
138+
139+
// No changes in the setter API, but the keys are remapped to:
140+
// ?lat=45.18&lng=5.72
141+
setCoordinates({
142+
latitude: 45.18,
143+
longitude: 5.72
144+
})
145+
```
146+

Diff for: packages/e2e/cypress/e2e/remapped-keys.cy.js

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
/// <reference types="cypress" />
2+
3+
it('Remapped keys', () => {
4+
cy.visit('/app/remapped-keys')
5+
cy.contains('#hydration-marker', 'hydrated').should('be.hidden')
6+
cy.get('#search').type('a')
7+
cy.get('#page').clear().type('42')
8+
cy.get('#react').check()
9+
cy.get('#nextjs').check()
10+
cy.location('search').should('eq', '?q=a&page=42&tags=react,next.js')
11+
})

Diff for: packages/e2e/src/app/app/remapped-keys/page.tsx

+92
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
'use client'
2+
3+
import {
4+
parseAsArrayOf,
5+
parseAsInteger,
6+
parseAsString,
7+
useQueryStates
8+
} from 'nuqs'
9+
import { Suspense } from 'react'
10+
11+
export default function Page() {
12+
return (
13+
<Suspense>
14+
<Client />
15+
</Suspense>
16+
)
17+
}
18+
19+
function Client() {
20+
const [{ searchQuery, pageNumber, activeTags }, setURLState] = useQueryStates(
21+
{
22+
searchQuery: parseAsString.withDefault(''),
23+
pageNumber: parseAsInteger.withDefault(1),
24+
activeTags: parseAsArrayOf(parseAsString).withDefault([])
25+
},
26+
{
27+
clearOnDefault: true,
28+
urlKeys: {
29+
searchQuery: 'q',
30+
pageNumber: 'page',
31+
activeTags: 'tags'
32+
}
33+
}
34+
)
35+
36+
return (
37+
<>
38+
<label style={{ display: 'block' }}>
39+
<input
40+
id="search"
41+
value={searchQuery}
42+
onChange={e => setURLState({ searchQuery: e.target.value })}
43+
/>
44+
<span>Search</span>
45+
</label>
46+
<label style={{ display: 'block' }}>
47+
<input
48+
id="page"
49+
type="number"
50+
min={1}
51+
max={5}
52+
step={1}
53+
value={pageNumber}
54+
onChange={e => setURLState({ pageNumber: e.target.valueAsNumber })}
55+
/>
56+
<span>Page</span>
57+
</label>
58+
<label style={{ display: 'block' }}>
59+
<label>
60+
<input
61+
id="react"
62+
type="checkbox"
63+
checked={activeTags.includes('react')}
64+
onChange={e =>
65+
setURLState(old => ({
66+
activeTags: e.target.checked
67+
? [...old.activeTags, 'react']
68+
: old.activeTags.filter(tag => !tag.includes('react'))
69+
}))
70+
}
71+
/>
72+
React SPA
73+
</label>
74+
<label>
75+
<input
76+
id="nextjs"
77+
type="checkbox"
78+
checked={activeTags.includes('next.js')}
79+
onChange={e =>
80+
setURLState(old => ({
81+
activeTags: e.target.checked
82+
? [...old.activeTags, 'next.js']
83+
: old.activeTags.filter(tag => !tag.includes('next.js'))
84+
}))
85+
}
86+
/>
87+
Next.js
88+
</label>
89+
</label>
90+
</>
91+
)
92+
}

Diff for: packages/nuqs/src/tests/useQueryStates.test-d.ts

+42-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { expectNotAssignable, expectType } from 'tsd'
1+
import { expectError, expectNotAssignable, expectType } from 'tsd'
22
import {
33
parseAsBoolean,
44
parseAsFloat,
@@ -77,3 +77,44 @@ import {
7777
bin: Buffer
7878
}>(states)
7979
}
80+
81+
// Remapped keys
82+
{
83+
const [states, setStates] = useQueryStates(
84+
{
85+
foo: parseAsString,
86+
bar: parseAsString
87+
},
88+
{
89+
urlKeys: {
90+
foo: 'f'
91+
// bar: 'b' // allows partial remapping
92+
}
93+
}
94+
)
95+
expectType<{
96+
foo: string | null
97+
bar: string | null
98+
}>(states)
99+
setStates({
100+
foo: 'baz',
101+
bar: 'qux'
102+
})
103+
}
104+
105+
// Remapped keys
106+
{
107+
expectError(() => {
108+
useQueryStates(
109+
{
110+
foo: parseAsString,
111+
bar: parseAsString
112+
},
113+
{
114+
urlKeys: {
115+
notInTheList: 'f'
116+
}
117+
}
118+
)
119+
})
120+
}

0 commit comments

Comments
 (0)