Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 127 additions & 0 deletions packages/docs/content/docs/options.client.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
'use client'

import { Button } from '@/src/components/ui/button'
import { Checkbox } from '@/src/components/ui/checkbox'
import { Label } from '@/src/components/ui/label'
import { parseAsInteger, useQueryState } from 'nuqs'
import { NuqsAdapter } from 'nuqs/adapters/react'
import { useEffect, useState } from 'react'

export function DemoSkeleton() {
return (
<figure className="flex animate-pulse flex-wrap justify-around gap-2 rounded-md border border-dashed p-2">
<ComponentSkeleton />
<ComponentSkeleton />
<ComponentSkeleton />
</figure>
)
}

function sortAlphabetically(search: URLSearchParams): URLSearchParams {
const entries = Array.from(search.entries())
entries.sort(([a], [b]) => a.localeCompare(b))
return new URLSearchParams(entries)
}
function passThrough(search: URLSearchParams): URLSearchParams {
return search
}

export function AlphabeticalSortDemo() {
const [hydrated, setHydrated] = useState(false)
const [enableSorting, setEnableSorting] = useState(true)
useEffect(() => {
setHydrated(true)
}, [])

if (!hydrated) {
return <DemoSkeleton />
}

return (
<NuqsAdapter
processUrlSearchParams={enableSorting ? sortAlphabetically : passThrough}
>
<>
<Label className="flex items-center gap-2">
<Checkbox
checked={enableSorting}
onCheckedChange={checked => setEnableSorting(checked === true)}
/>{' '}
Enable alphabetical sorting on updates
</Label>
<figure className="not-prose mt-4 mb-8 flex flex-wrap justify-around gap-2 rounded-md border border-dashed p-2">
<ComponentToggle id="a" />
<ComponentToggle id="b" />
<ComponentToggle id="c" />
</figure>
</>
</NuqsAdapter>
)
}

export function TimestampDemo() {
const [hydrated, setHydrated] = useState(false)
useEffect(() => {
setHydrated(true)
}, [])

if (!hydrated) {
return <DemoSkeleton />
}

return (
<NuqsAdapter
processUrlSearchParams={search => {
const params = new URLSearchParams(search)
params.set('ts', Date.now().toString())
return params
}}
>
<figure className="flex flex-wrap justify-around gap-2 rounded-md border border-dashed p-2">
<ComponentIncrement id="d" />
<ComponentIncrement id="e" />
<ComponentIncrement id="f" />
</figure>
</NuqsAdapter>
)
}

function ComponentIncrement({ id }: { id: string }) {
const [count, setCount] = useQueryState(id, parseAsInteger.withDefault(0))

return (
<div className="rounded-xl p-1.5">
<Button
onClick={() => setCount(c => c + 1)}
className="min-w-42 tabular-nums"
>
Increment "{id}": {count}
</Button>
</div>
)
}

function ComponentToggle({ id }: { id: string }) {
const [, setCount] = useQueryState(id, parseAsInteger.withDefault(0))

return (
<div className="rounded-xl p-1.5">
<Button
onClick={() => setCount(c => (c ? 0 : 1))}
className="min-w-42 tabular-nums"
>
Toggle "{id}"
</Button>
</div>
)
}

function ComponentSkeleton() {
return (
<div className="rounded-xl p-1.5">
<Button disabled className="min-w-42 tabular-nums">
Loading demo...
</Button>
</div>
)
}
118 changes: 92 additions & 26 deletions packages/docs/content/docs/options.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ description: Configuring nuqs
By default, `nuqs` will update search params:
1. On the client only (not sending requests to the server),
2. by replacing the current history entry,
3. and without scrolling to the top of the page.

These behaviours can be configured, along with a few additional options.
3. without scrolling to the top of the page.
4. with a throttle rate adapted to your browser

These default behaviours can be [configured](#global-defaults-override),
along with a few additional options.

## Passing options

Expand All @@ -32,27 +33,6 @@ setState('foo', { scroll: true })

Call-level options will override hook level options.

### Global defaults override

<FeatureSupportMatrix introducedInVersion='2.5.0' />

Default values for some options can also be configured globally via the `defaultOptions{:ts}`
[adapter](/docs/adapters) prop:

```tsx
// [!code word:defaultOptions]
<NuqsAdapter
defaultOptions={{
shallow: false,
scroll: true,
clearOnDefault: false,
limitUrlUpdates: throttle(250),
}}
>
{children}
</NuqsAdapter>
```

## History

By default, state updates are done by **replacing** the current history entry with
Expand Down Expand Up @@ -149,7 +129,6 @@ can opt-in to this behaviour:
useQueryState('foo', { scroll: true })
```


## Rate-limiting URL updates

Because of browsers rate-limiting the History API, updates **to the
Expand Down Expand Up @@ -309,7 +288,6 @@ const [, setState] = useQueryState('foo', {
setState('bar', { limitUrlUpdates: defaultRateLimit })
```


## Transitions

When combined with `shallow: false{:ts}`, you can use React's `useTransition{:ts}` hook
Expand Down Expand Up @@ -394,3 +372,91 @@ const dateParser = createParser({
eq: (a: Date, b: Date) => a.getTime() === b.getTime() // [!code highlight]
})
```

## Adapter props

The following options are global and can be passed directly
on the `NuqsAdapter{:ts}` as props, and apply to its whole
children tree.

### Global defaults override

<FeatureSupportMatrix introducedInVersion='2.5.0' />

Default values for some options can be configured globally via the `defaultOptions{:ts}`
[adapter](/docs/adapters) prop:

```tsx
// [!code word:defaultOptions]
<NuqsAdapter
defaultOptions={{
shallow: false,
scroll: true,
clearOnDefault: false,
limitUrlUpdates: throttle(250),
}}
>
{children}
</NuqsAdapter>
```

### Processing `URLSearchParams`

<FeatureSupportMatrix introducedInVersion='2.6.0' />

You can pass a `processUrlSearchParams{:ts}` callback to the adapter,
which will be called _after_ the `URLSearchParams{:ts}` have been merged
when performing a state update, and _before_ they are sent to the adapter
for updating the URL.

Think of it as a sort of **middleware** for processing search params.

#### Alphabetical Sort

Sort the search parameters alphabetically:

```tsx
// [!code word:processUrlSearchParams]
<NuqsAdapter
processUrlSearchParams={(search) => {
const entries = Array.from(search.entries())
entries.sort(([a], [b]) => a.localeCompare(b))
return new URLSearchParams(entries)
}}
Comment on lines +421 to +425
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was it considered to recommend the usage of the native sort() function on URLSearchParams?

Something along the lines of:

Suggested change
processUrlSearchParams={(search) => {
const entries = Array.from(search.entries())
entries.sort(([a], [b]) => a.localeCompare(b))
return new URLSearchParams(entries)
}}
processUrlSearchParams={(search) => {
const clone = new URLSearchParams(search)
clone.sort()
return clone
}}

Whilst it isn't the exact same sorting algorithm, I was thinking maybe it'd be preferable to recommend following the standard by default!

I can update the docs if you agree 😄

Copy link
Contributor Author

@Multiply Multiply Sep 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we don't care about immutability, simply call search.sort() and returning search directly could also do.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Imagine building an entire library around URLSearchParams and only discovering the .sort function now 😅

Yes standards would be better to advertise in the docs. It's a shame it doesn't return itself though, that could have been a one-liner.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

>
{children}
</NuqsAdapter>
```

import { Suspense } from 'react'
import { AlphabeticalSortDemo, DemoSkeleton } from './options.client'

_Try toggling **c**, then **b**, then **a**, and note how the URL remains ordered:_

<Suspense fallback={<DemoSkeleton/>}>
<AlphabeticalSortDemo />
</Suspense>

#### Timestamp

Add a timestamp to the search parameters:

```tsx
// [!code word:processUrlSearchParams]
<NuqsAdapter
processUrlSearchParams={(search) => {
// Note: you can modify search in-place,
// or return a copy, as you wish.
search.set('ts', Date.now().toString())
return search
}}
>
{children}
</NuqsAdapter>
```

import { TimestampDemo } from './options.client'

<Suspense fallback={<DemoSkeleton/>}>
<TimestampDemo />
</Suspense>
12 changes: 10 additions & 2 deletions packages/nuqs/src/adapters/lib/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export type AdapterProps = {
defaultOptions?: Partial<
Pick<Options, 'shallow' | 'clearOnDefault' | 'scroll' | 'limitUrlUpdates'>
>
processUrlSearchParams?: (search: URLSearchParams) => URLSearchParams
}

export type AdapterContext = AdapterProps & {
Expand Down Expand Up @@ -59,10 +60,13 @@ export type AdapterProvider = (
export function createAdapterProvider(
useAdapter: UseAdapterHook
): AdapterProvider {
return ({ children, defaultOptions, ...props }) =>
return ({ children, defaultOptions, processUrlSearchParams, ...props }) =>
createElement(
context.Provider,
{ ...props, value: { useAdapter, defaultOptions } },
{
...props,
value: { useAdapter, defaultOptions, processUrlSearchParams }
},
children
)
}
Expand All @@ -77,3 +81,7 @@ export function useAdapter(watchKeys: string[]): AdapterInterface {

export const useAdapterDefaultOptions = (): AdapterProps['defaultOptions'] =>
useContext(context).defaultOptions

export const useAdapterProcessUrlSearchParams =
(): AdapterProps['processUrlSearchParams'] =>
useContext(context).processUrlSearchParams
3 changes: 2 additions & 1 deletion packages/nuqs/src/adapters/testing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ type TestingAdapterProps = {
export function NuqsTestingAdapter({
resetUrlUpdateQueueOnMount = true,
defaultOptions,
processUrlSearchParams,
...props
}: TestingAdapterProps): ReactElement {
if (resetUrlUpdateQueueOnMount) {
Expand Down Expand Up @@ -59,7 +60,7 @@ export function NuqsTestingAdapter({
})
return createElement(
context.Provider,
{ value: { useAdapter, defaultOptions } },
{ value: { useAdapter, defaultOptions, processUrlSearchParams } },
props.children
)
}
Expand Down
58 changes: 58 additions & 0 deletions packages/nuqs/src/lib/queues/throttle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,4 +305,62 @@ describe('throttle: flush', () => {
new Error('updateUrl error')
)
})
it('should process url search params', async () => {
const mockAdapter = createMockAdapter()
const queue = new ThrottledQueue()
queue.push({
key: 'a',
query: 'a',
options: {}
})
const promise = queue.flush(mockAdapter, function (search) {
const params = new URLSearchParams(search)
params.set('b', 'b')
return params
})
expect(queue.controller).not.toBeNull()
vi.runAllTimers()
await expect(promise).resolves.toEqual(new URLSearchParams('?a=a&b=b'))
})
describe('should process url search params', () => {
it('should add new params', async () => {
const mockAdapter = createMockAdapter()
const queue = new ThrottledQueue()
queue.push({
key: 'a',
query: 'a',
options: {}
})
const promise = queue.flush(mockAdapter, search => {
const params = new URLSearchParams(search)
params.set('b', 'b')
return params
})
expect(queue.controller).not.toBeNull()
vi.runAllTimers()
await expect(promise).resolves.toEqual(new URLSearchParams('?a=a&b=b'))
})
it('should sort params', async () => {
const mockAdapter = createMockAdapter()
const queue = new ThrottledQueue()
queue.push({
key: 'b',
query: 'b',
options: {}
})
queue.push({
key: 'a',
query: 'a',
options: {}
})
const promise = queue.flush(mockAdapter, search => {
const entries = Array.from(search.entries())
entries.sort(([a], [b]) => a.localeCompare(b))
return new URLSearchParams(entries)
})
expect(queue.controller).not.toBeNull()
vi.runAllTimers()
await expect(promise).resolves.toEqual(new URLSearchParams('?a=a&b=b'))
})
})
})
Loading
Loading