From 9dadf5c58a719d598a8b640ad22bcb1a4ebd1302 Mon Sep 17 00:00:00 2001 From: TkDodo Date: Sat, 4 Oct 2025 16:27:15 +0200 Subject: [PATCH 1/6] ref: bail out of state updates for equal values --- packages/nuqs/src/useQueryStates.ts | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/packages/nuqs/src/useQueryStates.ts b/packages/nuqs/src/useQueryStates.ts index 8d51d0a54..815481603 100644 --- a/packages/nuqs/src/useQueryStates.ts +++ b/packages/nuqs/src/useQueryStates.ts @@ -189,11 +189,6 @@ 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] = ({ @@ -218,7 +213,27 @@ export function useQueryStates( defaultValue, stateRef.current ) - updateInternalState(stateRef.current) + if ( + Object.is( + internalState[stateKey as keyof KeyMap] ?? defaultValue ?? null, + stateRef.current[stateKey as keyof KeyMap] + ) + ) { + debug( + '[nuq+ %s `%s`] Cross-hook key sync %s: no change, skipping', + hookId, + stateKeys, + urlKey + ) + return + } + debug( + '[nuq+ %s `%s`] updateInternalState %O', + hookId, + stateKeys, + stateRef.current + ) + setInternalState(stateRef.current) } return handlers }, From 8ce4dafb2583be0409a100307cff54ad31591309 Mon Sep 17 00:00:00 2001 From: TkDodo Date: Sat, 4 Oct 2025 17:11:16 +0200 Subject: [PATCH 2/6] fix: bail-out must happen before writing to refs --- packages/nuqs/src/useQueryStates.ts | 28 +++++++++++++--------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/packages/nuqs/src/useQueryStates.ts b/packages/nuqs/src/useQueryStates.ts index 815481603..e355c10b5 100644 --- a/packages/nuqs/src/useQueryStates.ts +++ b/packages/nuqs/src/useQueryStates.ts @@ -197,11 +197,23 @@ export function useQueryStates( }: CrossHookSyncPayload) => { const { defaultValue } = keyMap[stateKey]! const urlKey = resolvedUrlKeys[stateKey]! + const currentValue = + internalState[stateKey as keyof KeyMap] ?? defaultValue ?? null + const nextValue = state ?? defaultValue ?? null + if (Object.is(currentValue, nextValue)) { + debug( + '[nuq+ %s `%s`] Cross-hook key sync %s: no change, skipping', + hookId, + stateKeys, + urlKey + ) + return + } // 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 + [stateKey as keyof KeyMap]: nextValue } queryRef.current[urlKey] = query debug( @@ -213,20 +225,6 @@ export function useQueryStates( defaultValue, stateRef.current ) - if ( - Object.is( - internalState[stateKey as keyof KeyMap] ?? defaultValue ?? null, - stateRef.current[stateKey as keyof KeyMap] - ) - ) { - debug( - '[nuq+ %s `%s`] Cross-hook key sync %s: no change, skipping', - hookId, - stateKeys, - urlKey - ) - return - } debug( '[nuq+ %s `%s`] updateInternalState %O', hookId, From acbd961e132d1cd77825ca4ab53328aee47c735d Mon Sep 17 00:00:00 2001 From: TkDodo Date: Sat, 4 Oct 2025 20:37:17 +0200 Subject: [PATCH 3/6] fix: stale closure --- packages/nuqs/src/useQueryStates.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nuqs/src/useQueryStates.ts b/packages/nuqs/src/useQueryStates.ts index e355c10b5..6ba7de775 100644 --- a/packages/nuqs/src/useQueryStates.ts +++ b/packages/nuqs/src/useQueryStates.ts @@ -198,7 +198,7 @@ export function useQueryStates( const { defaultValue } = keyMap[stateKey]! const urlKey = resolvedUrlKeys[stateKey]! const currentValue = - internalState[stateKey as keyof KeyMap] ?? defaultValue ?? null + stateRef.current[stateKey as keyof KeyMap] ?? defaultValue ?? null const nextValue = state ?? defaultValue ?? null if (Object.is(currentValue, nextValue)) { debug( From fb3d8f17068e8d7345b9dde0129a9abcca59dacd Mon Sep 17 00:00:00 2001 From: TkDodo Date: Sat, 4 Oct 2025 20:47:38 +0200 Subject: [PATCH 4/6] ref: access previousState with state updater callback --- packages/nuqs/src/useQueryStates.ts | 70 +++++++++++++++-------------- 1 file changed, 37 insertions(+), 33 deletions(-) diff --git a/packages/nuqs/src/useQueryStates.ts b/packages/nuqs/src/useQueryStates.ts index 6ba7de775..336b75aaa 100644 --- a/packages/nuqs/src/useQueryStates.ts +++ b/packages/nuqs/src/useQueryStates.ts @@ -195,43 +195,47 @@ export function useQueryStates( state, query }: CrossHookSyncPayload) => { - const { defaultValue } = keyMap[stateKey]! - const urlKey = resolvedUrlKeys[stateKey]! - const currentValue = - stateRef.current[stateKey as keyof KeyMap] ?? defaultValue ?? null - const nextValue = state ?? defaultValue ?? null - if (Object.is(currentValue, nextValue)) { + setInternalState(currentValue => { + const { defaultValue } = keyMap[stateKey]! + const urlKey = resolvedUrlKeys[stateKey]! + const nextValue = state ?? 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 value + return currentValue + } + // 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: no change, skipping', + '[nuq+ %s `%s`] Cross-hook key sync %s: %O (default: %O). updateInternalState, resolved: %O', hookId, stateKeys, - urlKey + urlKey, + state, + defaultValue, + stateRef.current ) - return - } - // 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). Resolved: %O', - hookId, - stateKeys, - urlKey, - state, - defaultValue, - stateRef.current - ) - debug( - '[nuq+ %s `%s`] updateInternalState %O', - hookId, - stateKeys, - stateRef.current - ) - setInternalState(stateRef.current) + debug( + '[nuq+ %s `%s`] updateInternalState %O', + hookId, + stateKeys, + stateRef.current + ) + return stateRef.current + }) } return handlers }, From 8702ffff5285199bb10203840d04cfb70eb2ba5c Mon Sep 17 00:00:00 2001 From: TkDodo Date: Sat, 4 Oct 2025 20:50:21 +0200 Subject: [PATCH 5/6] ref: remove duplicate debug statement --- packages/nuqs/src/useQueryStates.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/packages/nuqs/src/useQueryStates.ts b/packages/nuqs/src/useQueryStates.ts index 336b75aaa..de6bdda19 100644 --- a/packages/nuqs/src/useQueryStates.ts +++ b/packages/nuqs/src/useQueryStates.ts @@ -228,12 +228,6 @@ export function useQueryStates( defaultValue, stateRef.current ) - debug( - '[nuq+ %s `%s`] updateInternalState %O', - hookId, - stateKeys, - stateRef.current - ) return stateRef.current }) } From f17fe594d426b125b41bfdb56f660b1db360f341 Mon Sep 17 00:00:00 2001 From: TkDodo Date: Mon, 6 Oct 2025 11:09:30 +0200 Subject: [PATCH 6/6] test: bail-out --- packages/nuqs/src/useQueryStates.test.tsx | 49 ++++++++++++++++++++++- packages/nuqs/src/useQueryStates.ts | 8 ++-- 2 files changed, 53 insertions(+), 4 deletions(-) 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 de6bdda19..b954fdae0 100644 --- a/packages/nuqs/src/useQueryStates.ts +++ b/packages/nuqs/src/useQueryStates.ts @@ -195,10 +195,12 @@ export function useQueryStates( state, query }: CrossHookSyncPayload) => { - setInternalState(currentValue => { + 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', @@ -209,8 +211,8 @@ export function useQueryStates( defaultValue, stateRef.current ) - // bail out by returning the current value - return currentValue + // 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.