diff --git a/packages/nuqs/src/useQueryStates.test.tsx b/packages/nuqs/src/useQueryStates.test.tsx
index 6168a3ee3..c0bbcde43 100644
--- a/packages/nuqs/src/useQueryStates.test.tsx
+++ b/packages/nuqs/src/useQueryStates.test.tsx
@@ -1,6 +1,11 @@
import { act, render, renderHook, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
-import React, { createElement, useState, type ReactNode } from 'react'
+import React, {
+ createElement,
+ useState,
+ type ReactNode,
+ useEffect
+} from 'react'
import { describe, expect, it, vi } from 'vitest'
import {
NullDetector,
@@ -201,6 +206,48 @@ describe('useQueryStates: referential equality', () => {
})
})
+describe('useQueryStates: rendering & bail-out', () => {
+ it('should bail out of rendering the same component when setting to the same value', async () => {
+ let renderCount = 0
+ function TestComponent() {
+ const [{ test }, setSearchParams] = useQueryStates({
+ test: parseAsString
+ })
+ useEffect(() => {
+ renderCount++
+ })
+ return (
+ <>
+
+
value: {test}
+ >
+ )
+ }
+ const user = userEvent.setup()
+ const onUrlUpdate = vi.fn()
+ render(, {
+ wrapper: withNuqsTestingAdapter({
+ onUrlUpdate,
+ searchParams: '?test=init'
+ })
+ })
+ await expect(screen.findByText('value: init')).resolves.toBeInTheDocument()
+ expect(renderCount).toBe(1)
+ expect(onUrlUpdate).toHaveBeenCalledTimes(0)
+
+ await user.click(screen.getByRole('button', { name: 'Start' }))
+
+ expect(renderCount).toBe(1) // same render count as before
+ expect(onUrlUpdate).toHaveBeenCalledTimes(1) // url update is still called
+ })
+})
+
describe('useQueryStates: urlKeys remapping', () => {
it('uses the object key names by default', async () => {
const onUrlUpdate = vi.fn()
diff --git a/packages/nuqs/src/useQueryStates.ts b/packages/nuqs/src/useQueryStates.ts
index 569ed58e6..7734f7b02 100644
--- a/packages/nuqs/src/useQueryStates.ts
+++ b/packages/nuqs/src/useQueryStates.ts
@@ -194,36 +194,49 @@ export function useQueryStates(
// Sync all hooks together & with external URL changes
useEffect(() => {
- function updateInternalState(state: V) {
- debug('[nuq+ %s `%s`] updateInternalState %O', hookId, stateKeys, state)
- stateRef.current = state
- setInternalState(state)
- }
const handlers = Object.keys(keyMap).reduce(
(handlers, stateKey) => {
handlers[stateKey as keyof KeyMap] = ({
state,
query
}: CrossHookSyncPayload) => {
- const { defaultValue } = keyMap[stateKey]!
- const urlKey = resolvedUrlKeys[stateKey]!
- // Note: cannot mutate in-place, the object ref must change
- // for the subsequent setState to pick it up.
- stateRef.current = {
- ...stateRef.current,
- [stateKey as keyof KeyMap]: state ?? defaultValue ?? null
- }
- queryRef.current[urlKey] = query
- debug(
- '[nuq+ %s `%s`] Cross-hook key sync %s: %O (default: %O). Resolved: %O',
- hookId,
- stateKeys,
- urlKey,
- state,
- defaultValue,
- stateRef.current
- )
- updateInternalState(stateRef.current)
+ setInternalState(currentState => {
+ const { defaultValue } = keyMap[stateKey]!
+ const urlKey = resolvedUrlKeys[stateKey]!
+ const nextValue = state ?? defaultValue ?? null
+ const currentValue = currentState[stateKey] ?? defaultValue ?? null
+
+ if (Object.is(currentValue, nextValue)) {
+ debug(
+ '[nuq+ %s `%s`] Cross-hook key sync %s: %O (default: %O). no change, skipping, resolved: %O',
+ hookId,
+ stateKeys,
+ urlKey,
+ state,
+ defaultValue,
+ stateRef.current
+ )
+ // bail out by returning the current state
+ return currentState
+ }
+ // Note: cannot mutate in-place, the object ref must change
+ // for the subsequent setState to pick it up.
+ stateRef.current = {
+ ...stateRef.current,
+ [stateKey as keyof KeyMap]: nextValue
+ }
+ queryRef.current[urlKey] = query
+ debug(
+ '[nuq+ %s `%s`] Cross-hook key sync %s: %O (default: %O). updateInternalState, resolved: %O',
+ hookId,
+ stateKeys,
+ urlKey,
+ state,
+ defaultValue,
+ stateRef.current
+ )
+ return stateRef.current
+ })
}
return handlers
},