diff --git a/packages/e2e/next/cypress/e2e/repro-760.cy.js b/packages/e2e/next/cypress/e2e/repro-760.cy.js
new file mode 100644
index 000000000..b5274dead
--- /dev/null
+++ b/packages/e2e/next/cypress/e2e/repro-760.cy.js
@@ -0,0 +1,14 @@
+///
+
+describe('repro-760', () => {
+ it('supports dynamic default values', () => {
+ cy.visit('/app/repro-760')
+ cy.contains('#hydration-marker', 'hydrated').should('be.hidden')
+ cy.get('#value-a').should('have.text', 'a')
+ cy.get('#value-b').should('have.text', 'b')
+ cy.get('#trigger-a').click()
+ cy.get('#trigger-b').click()
+ cy.get('#value-a').should('have.text', 'pass')
+ cy.get('#value-b').should('have.text', 'pass')
+ })
+})
diff --git a/packages/e2e/next/src/app/app/repro-760/page.tsx b/packages/e2e/next/src/app/app/repro-760/page.tsx
new file mode 100644
index 000000000..08382b46a
--- /dev/null
+++ b/packages/e2e/next/src/app/app/repro-760/page.tsx
@@ -0,0 +1,44 @@
+'use client'
+
+import { parseAsString, useQueryState, useQueryStates } from 'nuqs'
+import { Suspense, useState } from 'react'
+
+export default function Page() {
+ return (
+
+
+
+
+ )
+}
+
+function DynamicUseQueryState() {
+ const [defaultValue, setDefaultValue] = useState('a')
+ const [value] = useQueryState('a', parseAsString.withDefault(defaultValue))
+ return (
+
+
+ {value}
+
+ )
+}
+
+function DynamicUseQueryStates() {
+ const [defaultValue, setDefaultValue] = useState('b')
+ const [{ value }] = useQueryStates(
+ {
+ value: parseAsString.withDefault(defaultValue)
+ },
+ { urlKeys: { value: 'b' } }
+ )
+ return (
+
+
+ {value}
+
+ )
+}
diff --git a/packages/nuqs/src/useQueryStates.test.tsx b/packages/nuqs/src/useQueryStates.test.tsx
new file mode 100644
index 000000000..eda6b3623
--- /dev/null
+++ b/packages/nuqs/src/useQueryStates.test.tsx
@@ -0,0 +1,95 @@
+import { act, renderHook } from '@testing-library/react'
+import type { ReactNode } from 'react'
+import React from 'react'
+import { describe, expect, it } from 'vitest'
+import { NuqsTestingAdapter } from './adapters/testing'
+import { parseAsArrayOf, parseAsJson, parseAsString } from './parsers'
+import { useQueryStates } from './useQueryStates'
+
+function withSearchParams(
+ searchParams?: string | URLSearchParams | Record
+) {
+ return (props: { children: ReactNode }) => (
+
+ )
+}
+
+const defaults = {
+ str: 'foo',
+ obj: { initial: 'state' },
+ arr: [
+ {
+ initial: 'state'
+ }
+ ]
+}
+
+const hook = ({ defaultValue } = { defaultValue: defaults.str }) => {
+ return useQueryStates({
+ str: parseAsString.withDefault(defaultValue),
+ obj: parseAsJson(x => x).withDefault(defaults.obj),
+ arr: parseAsArrayOf(parseAsJson(x => x)).withDefault(defaults.arr)
+ })
+}
+
+describe('useQueryStates', () => {
+ it('should have referential equality on default values', () => {
+ const { result } = renderHook(hook, {
+ wrapper: NuqsTestingAdapter
+ })
+ const [state] = result.current
+ expect(state.str).toBe(defaults.str)
+ expect(state.obj).toBe(defaults.obj)
+ expect(state.arr).toBe(defaults.arr)
+ expect(state.arr[0]).toBe(defaults.arr[0])
+ })
+
+ it('should keep referential equality when resetting to defaults', () => {
+ const { result } = renderHook(hook, {
+ wrapper: withSearchParams({
+ str: 'foo',
+ obj: '{"hello":"world"}',
+ arr: '{"obj":true},{"arr":true}'
+ })
+ })
+ act(() => {
+ result.current[1](null)
+ })
+ const [state] = result.current
+ expect(state.str).toBe(defaults.str)
+ expect(state.obj).toBe(defaults.obj)
+ expect(state.arr).toBe(defaults.arr)
+ expect(state.arr[0]).toBe(defaults.arr[0])
+ })
+
+ it('should keep referential equality when unrelated keys change', () => {
+ const { result } = renderHook(hook, {
+ wrapper: withSearchParams({
+ str: 'foo',
+ obj: '{"hello":"world"}'
+ // Keep arr as default
+ })
+ })
+ const [{ obj: initialObj, arr: initialArr }] = result.current
+ act(() => {
+ result.current[1]({ str: 'bar' })
+ })
+ const [{ str, obj, arr }] = result.current
+ expect(str).toBe('bar')
+ expect(obj).toBe(initialObj)
+ expect(arr).toBe(initialArr)
+ })
+
+ it('should keep referential equality when default changes for another key', () => {
+ const { result, rerender } = renderHook(hook, {
+ wrapper: withSearchParams()
+ })
+ expect(result.current[0].str).toBe('foo')
+ rerender({ defaultValue: 'b' })
+ const [state] = result.current
+ expect(state.str).toBe('b')
+ expect(state.obj).toBe(defaults.obj)
+ expect(state.arr).toBe(defaults.arr)
+ expect(state.arr[0]).toBe(defaults.arr[0])
+ })
+})
diff --git a/packages/nuqs/src/useQueryStates.ts b/packages/nuqs/src/useQueryStates.ts
index c96f81012..6ece5c74f 100644
--- a/packages/nuqs/src/useQueryStates.ts
+++ b/packages/nuqs/src/useQueryStates.ts
@@ -40,6 +40,7 @@ export type Values = {
? NonNullable>
: ReturnType | null
}
+type NullableValues = Nullable>
type UpdaterFn = (
old: Values
@@ -80,7 +81,7 @@ export function useQueryStates(
urlKeys = defaultUrlKeys
}: Partial> = {}
): UseQueryStatesReturn {
- type V = Values
+ type V = NullableValues
const stateKeys = Object.keys(keyMap).join(',')
const resolvedUrlKeys = useMemo(
() =>
@@ -99,6 +100,17 @@ export function useQueryStates(
if (Object.keys(queryRef.current).length !== Object.keys(keyMap).length) {
queryRef.current = Object.fromEntries(initialSearchParams?.entries() ?? [])
}
+ const defaultValues = useMemo(
+ () =>
+ Object.fromEntries(
+ Object.keys(keyMap).map(key => [key, keyMap[key]!.defaultValue ?? null])
+ ) as Values,
+ [
+ Object.values(keyMap)
+ .map(({ defaultValue }) => defaultValue)
+ .join(',')
+ ]
+ )
const [internalState, setInternalState] = useState(() => {
const source = initialSearchParams ?? new URLSearchParams()
@@ -137,7 +149,7 @@ export function useQueryStates(
}
const handlers = Object.keys(keyMap).reduce(
(handlers, stateKey) => {
- handlers[stateKey as keyof V] = ({
+ handlers[stateKey as keyof KeyMap] = ({
state,
query
}: CrossHookSyncPayload) => {
@@ -147,7 +159,7 @@ export function useQueryStates(
// for the subsequent setState to pick it up.
stateRef.current = {
...stateRef.current,
- [stateKey as keyof V]: state ?? defaultValue ?? null
+ [stateKey as keyof KeyMap]: state ?? defaultValue ?? null
}
queryRef.current[urlKey] = query
debug(
@@ -162,7 +174,7 @@ export function useQueryStates(
}
return handlers
},
- {} as Record void>
+ {} as Record void>
)
for (const stateKey of Object.keys(keyMap)) {
@@ -183,7 +195,7 @@ export function useQueryStates(
(stateUpdater, callOptions = {}) => {
const newState: Partial> =
typeof stateUpdater === 'function'
- ? stateUpdater(stateRef.current)
+ ? stateUpdater(applyDefaultValues(stateRef.current, defaultValues))
: stateUpdater === null
? (Object.fromEntries(
Object.keys(keyMap).map(key => [key, null])
@@ -241,10 +253,16 @@ export function useQueryStates(
startTransition,
resolvedUrlKeys,
updateUrl,
- rateLimitFactor
+ rateLimitFactor,
+ defaultValues
]
)
- return [internalState, update]
+
+ const outputState = useMemo(
+ () => applyDefaultValues(internalState, defaultValues),
+ [internalState, defaultValues]
+ )
+ return [outputState, update]
}
// --
@@ -254,26 +272,34 @@ function parseMap(
urlKeys: Partial>,
searchParams: URLSearchParams,
cachedQuery?: Record,
- cachedState?: Values
-) {
+ cachedState?: NullableValues
+): NullableValues {
return Object.keys(keyMap).reduce((obj, stateKey) => {
const urlKey = urlKeys?.[stateKey] ?? stateKey
- const { defaultValue, parse } = keyMap[stateKey]!
+ const { parse } = keyMap[stateKey]!
const queuedQuery = getQueuedValue(urlKey)
const query =
queuedQuery === undefined
? (searchParams?.get(urlKey) ?? null)
: queuedQuery
if (cachedQuery && cachedState && cachedQuery[urlKey] === query) {
- obj[stateKey as keyof KeyMap] =
- cachedState[stateKey] ?? defaultValue ?? null
+ obj[stateKey as keyof KeyMap] = cachedState[stateKey] ?? null
return obj
}
const value = query === null ? null : safeParse(parse, query, stateKey)
- obj[stateKey as keyof KeyMap] = value ?? defaultValue ?? null
+ obj[stateKey as keyof KeyMap] = value ?? null
if (cachedQuery) {
cachedQuery[urlKey] = query
}
return obj
- }, {} as Values)
+ }, {} as NullableValues)
+}
+
+function applyDefaultValues(
+ state: NullableValues,
+ defaults: Partial>
+) {
+ return Object.fromEntries(
+ Object.keys(state).map(key => [key, state[key] ?? defaults[key] ?? null])
+ ) as Values
}