Skip to content

Commit 6ee72ee

Browse files
authored
fix: Support for dynamic default values in useQueryStates (#762)
1 parent 777627e commit 6ee72ee

File tree

4 files changed

+193
-14
lines changed

4 files changed

+193
-14
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/// <reference types="cypress" />
2+
3+
describe('repro-760', () => {
4+
it('supports dynamic default values', () => {
5+
cy.visit('/app/repro-760')
6+
cy.contains('#hydration-marker', 'hydrated').should('be.hidden')
7+
cy.get('#value-a').should('have.text', 'a')
8+
cy.get('#value-b').should('have.text', 'b')
9+
cy.get('#trigger-a').click()
10+
cy.get('#trigger-b').click()
11+
cy.get('#value-a').should('have.text', 'pass')
12+
cy.get('#value-b').should('have.text', 'pass')
13+
})
14+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
'use client'
2+
3+
import { parseAsString, useQueryState, useQueryStates } from 'nuqs'
4+
import { Suspense, useState } from 'react'
5+
6+
export default function Page() {
7+
return (
8+
<Suspense>
9+
<DynamicUseQueryState />
10+
<DynamicUseQueryStates />
11+
</Suspense>
12+
)
13+
}
14+
15+
function DynamicUseQueryState() {
16+
const [defaultValue, setDefaultValue] = useState('a')
17+
const [value] = useQueryState('a', parseAsString.withDefault(defaultValue))
18+
return (
19+
<section>
20+
<button id="trigger-a" onClick={() => setDefaultValue('pass')}>
21+
Trigger
22+
</button>
23+
<span id="value-a">{value}</span>
24+
</section>
25+
)
26+
}
27+
28+
function DynamicUseQueryStates() {
29+
const [defaultValue, setDefaultValue] = useState('b')
30+
const [{ value }] = useQueryStates(
31+
{
32+
value: parseAsString.withDefault(defaultValue)
33+
},
34+
{ urlKeys: { value: 'b' } }
35+
)
36+
return (
37+
<section>
38+
<button id="trigger-b" onClick={() => setDefaultValue('pass')}>
39+
Trigger
40+
</button>
41+
<span id="value-b">{value}</span>
42+
</section>
43+
)
44+
}
+95
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { act, renderHook } from '@testing-library/react'
2+
import type { ReactNode } from 'react'
3+
import React from 'react'
4+
import { describe, expect, it } from 'vitest'
5+
import { NuqsTestingAdapter } from './adapters/testing'
6+
import { parseAsArrayOf, parseAsJson, parseAsString } from './parsers'
7+
import { useQueryStates } from './useQueryStates'
8+
9+
function withSearchParams(
10+
searchParams?: string | URLSearchParams | Record<string, string>
11+
) {
12+
return (props: { children: ReactNode }) => (
13+
<NuqsTestingAdapter searchParams={searchParams} {...props} />
14+
)
15+
}
16+
17+
const defaults = {
18+
str: 'foo',
19+
obj: { initial: 'state' },
20+
arr: [
21+
{
22+
initial: 'state'
23+
}
24+
]
25+
}
26+
27+
const hook = ({ defaultValue } = { defaultValue: defaults.str }) => {
28+
return useQueryStates({
29+
str: parseAsString.withDefault(defaultValue),
30+
obj: parseAsJson<any>(x => x).withDefault(defaults.obj),
31+
arr: parseAsArrayOf(parseAsJson<any>(x => x)).withDefault(defaults.arr)
32+
})
33+
}
34+
35+
describe('useQueryStates', () => {
36+
it('should have referential equality on default values', () => {
37+
const { result } = renderHook(hook, {
38+
wrapper: NuqsTestingAdapter
39+
})
40+
const [state] = result.current
41+
expect(state.str).toBe(defaults.str)
42+
expect(state.obj).toBe(defaults.obj)
43+
expect(state.arr).toBe(defaults.arr)
44+
expect(state.arr[0]).toBe(defaults.arr[0])
45+
})
46+
47+
it('should keep referential equality when resetting to defaults', () => {
48+
const { result } = renderHook(hook, {
49+
wrapper: withSearchParams({
50+
str: 'foo',
51+
obj: '{"hello":"world"}',
52+
arr: '{"obj":true},{"arr":true}'
53+
})
54+
})
55+
act(() => {
56+
result.current[1](null)
57+
})
58+
const [state] = result.current
59+
expect(state.str).toBe(defaults.str)
60+
expect(state.obj).toBe(defaults.obj)
61+
expect(state.arr).toBe(defaults.arr)
62+
expect(state.arr[0]).toBe(defaults.arr[0])
63+
})
64+
65+
it('should keep referential equality when unrelated keys change', () => {
66+
const { result } = renderHook(hook, {
67+
wrapper: withSearchParams({
68+
str: 'foo',
69+
obj: '{"hello":"world"}'
70+
// Keep arr as default
71+
})
72+
})
73+
const [{ obj: initialObj, arr: initialArr }] = result.current
74+
act(() => {
75+
result.current[1]({ str: 'bar' })
76+
})
77+
const [{ str, obj, arr }] = result.current
78+
expect(str).toBe('bar')
79+
expect(obj).toBe(initialObj)
80+
expect(arr).toBe(initialArr)
81+
})
82+
83+
it('should keep referential equality when default changes for another key', () => {
84+
const { result, rerender } = renderHook(hook, {
85+
wrapper: withSearchParams()
86+
})
87+
expect(result.current[0].str).toBe('foo')
88+
rerender({ defaultValue: 'b' })
89+
const [state] = result.current
90+
expect(state.str).toBe('b')
91+
expect(state.obj).toBe(defaults.obj)
92+
expect(state.arr).toBe(defaults.arr)
93+
expect(state.arr[0]).toBe(defaults.arr[0])
94+
})
95+
})

packages/nuqs/src/useQueryStates.ts

+40-14
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export type Values<T extends UseQueryStatesKeysMap> = {
4040
? NonNullable<ReturnType<T[K]['parse']>>
4141
: ReturnType<T[K]['parse']> | null
4242
}
43+
type NullableValues<T extends UseQueryStatesKeysMap> = Nullable<Values<T>>
4344

4445
type UpdaterFn<T extends UseQueryStatesKeysMap> = (
4546
old: Values<T>
@@ -80,7 +81,7 @@ export function useQueryStates<KeyMap extends UseQueryStatesKeysMap>(
8081
urlKeys = defaultUrlKeys
8182
}: Partial<UseQueryStatesOptions<KeyMap>> = {}
8283
): UseQueryStatesReturn<KeyMap> {
83-
type V = Values<KeyMap>
84+
type V = NullableValues<KeyMap>
8485
const stateKeys = Object.keys(keyMap).join(',')
8586
const resolvedUrlKeys = useMemo(
8687
() =>
@@ -99,6 +100,17 @@ export function useQueryStates<KeyMap extends UseQueryStatesKeysMap>(
99100
if (Object.keys(queryRef.current).length !== Object.keys(keyMap).length) {
100101
queryRef.current = Object.fromEntries(initialSearchParams?.entries() ?? [])
101102
}
103+
const defaultValues = useMemo(
104+
() =>
105+
Object.fromEntries(
106+
Object.keys(keyMap).map(key => [key, keyMap[key]!.defaultValue ?? null])
107+
) as Values<KeyMap>,
108+
[
109+
Object.values(keyMap)
110+
.map(({ defaultValue }) => defaultValue)
111+
.join(',')
112+
]
113+
)
102114

103115
const [internalState, setInternalState] = useState<V>(() => {
104116
const source = initialSearchParams ?? new URLSearchParams()
@@ -137,7 +149,7 @@ export function useQueryStates<KeyMap extends UseQueryStatesKeysMap>(
137149
}
138150
const handlers = Object.keys(keyMap).reduce(
139151
(handlers, stateKey) => {
140-
handlers[stateKey as keyof V] = ({
152+
handlers[stateKey as keyof KeyMap] = ({
141153
state,
142154
query
143155
}: CrossHookSyncPayload) => {
@@ -147,7 +159,7 @@ export function useQueryStates<KeyMap extends UseQueryStatesKeysMap>(
147159
// for the subsequent setState to pick it up.
148160
stateRef.current = {
149161
...stateRef.current,
150-
[stateKey as keyof V]: state ?? defaultValue ?? null
162+
[stateKey as keyof KeyMap]: state ?? defaultValue ?? null
151163
}
152164
queryRef.current[urlKey] = query
153165
debug(
@@ -162,7 +174,7 @@ export function useQueryStates<KeyMap extends UseQueryStatesKeysMap>(
162174
}
163175
return handlers
164176
},
165-
{} as Record<keyof V, (payload: CrossHookSyncPayload) => void>
177+
{} as Record<keyof KeyMap, (payload: CrossHookSyncPayload) => void>
166178
)
167179

168180
for (const stateKey of Object.keys(keyMap)) {
@@ -183,7 +195,7 @@ export function useQueryStates<KeyMap extends UseQueryStatesKeysMap>(
183195
(stateUpdater, callOptions = {}) => {
184196
const newState: Partial<Nullable<KeyMap>> =
185197
typeof stateUpdater === 'function'
186-
? stateUpdater(stateRef.current)
198+
? stateUpdater(applyDefaultValues(stateRef.current, defaultValues))
187199
: stateUpdater === null
188200
? (Object.fromEntries(
189201
Object.keys(keyMap).map(key => [key, null])
@@ -241,10 +253,16 @@ export function useQueryStates<KeyMap extends UseQueryStatesKeysMap>(
241253
startTransition,
242254
resolvedUrlKeys,
243255
updateUrl,
244-
rateLimitFactor
256+
rateLimitFactor,
257+
defaultValues
245258
]
246259
)
247-
return [internalState, update]
260+
261+
const outputState = useMemo(
262+
() => applyDefaultValues(internalState, defaultValues),
263+
[internalState, defaultValues]
264+
)
265+
return [outputState, update]
248266
}
249267

250268
// --
@@ -254,26 +272,34 @@ function parseMap<KeyMap extends UseQueryStatesKeysMap>(
254272
urlKeys: Partial<Record<keyof KeyMap, string>>,
255273
searchParams: URLSearchParams,
256274
cachedQuery?: Record<string, string | null>,
257-
cachedState?: Values<KeyMap>
258-
) {
275+
cachedState?: NullableValues<KeyMap>
276+
): NullableValues<KeyMap> {
259277
return Object.keys(keyMap).reduce((obj, stateKey) => {
260278
const urlKey = urlKeys?.[stateKey] ?? stateKey
261-
const { defaultValue, parse } = keyMap[stateKey]!
279+
const { parse } = keyMap[stateKey]!
262280
const queuedQuery = getQueuedValue(urlKey)
263281
const query =
264282
queuedQuery === undefined
265283
? (searchParams?.get(urlKey) ?? null)
266284
: queuedQuery
267285
if (cachedQuery && cachedState && cachedQuery[urlKey] === query) {
268-
obj[stateKey as keyof KeyMap] =
269-
cachedState[stateKey] ?? defaultValue ?? null
286+
obj[stateKey as keyof KeyMap] = cachedState[stateKey] ?? null
270287
return obj
271288
}
272289
const value = query === null ? null : safeParse(parse, query, stateKey)
273-
obj[stateKey as keyof KeyMap] = value ?? defaultValue ?? null
290+
obj[stateKey as keyof KeyMap] = value ?? null
274291
if (cachedQuery) {
275292
cachedQuery[urlKey] = query
276293
}
277294
return obj
278-
}, {} as Values<KeyMap>)
295+
}, {} as NullableValues<KeyMap>)
296+
}
297+
298+
function applyDefaultValues<KeyMap extends UseQueryStatesKeysMap>(
299+
state: NullableValues<KeyMap>,
300+
defaults: Partial<Values<KeyMap>>
301+
) {
302+
return Object.fromEntries(
303+
Object.keys(state).map(key => [key, state[key] ?? defaults[key] ?? null])
304+
) as Values<KeyMap>
279305
}

0 commit comments

Comments
 (0)