Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Debounce #900

Open
wants to merge 14 commits into
base: next
Choose a base branch
from
129 changes: 116 additions & 13 deletions packages/docs/content/docs/options.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -125,42 +125,145 @@ useQueryState('foo', { scroll: true })
```


## Throttling URL updates
## Rate-limiting URL updates

Because of browsers rate-limiting the History API, updates **to the
URL** are queued and throttled to a default of 50ms, which seems to satisfy
most browsers even when sending high-frequency query updates, like binding
to a text input or a slider.

Safari's rate limits are much higher and require a throttle of 120ms (320ms for older
versions of Safari).
Safari's rate limits are much higher and use a default throttle of 120ms
(320ms for older versions of Safari).

If you want to opt-in to a larger throttle time -- for example to reduce the amount
<Callout title="Note">
the state returned by the hook is always updated **instantly**, to keep UI responsive.
Only changes to the URL, and server requests when using `shallow: false`, are throttled.
</Callout>

This throttle time is configurable, and also allows you to debounce updates
instead.

<Callout title="Which one should I use?">
Throttle will emit the first update immediately, then batch updates at a slower
pace **regularly**. This is recommended for most low-frequency updates.

Debounce will push back the moment when the URL is updated when you set your state,
making it **eventually consistent**. This is recommended for high-frequency
updates where the last value is more interesting than the intermediate ones,
like a search input or moving a slider.

Read more about [debounce vs throttle](https://kettanaito.com/blog/debounce-vs-throttle).
</Callout>

### Throttle

If you want to increase the throttle time -- for example to reduce the amount
of requests sent to the server when paired with `shallow: false{:ts}` -- you can
specify it under the `throttleMs` option:
specify it under the `limitUrlUpdates` option:

```ts /throttleMs: 1000/
```ts /limitUrlUpdates/
useQueryState('foo', {
// Send updates to the server maximum once every second
shallow: false,
throttleMs: 1000
limitUrlUpdates: {
method: 'throttle',
timeMs: 1000
}
})
```

<Callout title="Note">
the state returned by the hook is always updated **instantly**, to keep UI responsive.
Only changes to the URL, and server requests when using `shallow: false`, are throttled.
</Callout>
// or using the shorthand:
import { throttle } from 'nuqs'

useQueryState('foo', {
shallow: false,
limitUrlUpdates: throttle(1000)
})
```

If multiple hooks set different throttle values on the same event loop tick,
the highest value will be used. Also, values lower than 50ms will be ignored,
to avoid rate-limiting issues.
[Read more](https://francoisbest.com/posts/2023/storing-react-state-in-the-url-with-nextjs#batching--throttling).

Specifying a `+Infinity{:ts}` value for `throttleMs` will **disable** updates to the
Specifying a `+Infinity{:ts}` value for throttle time will **disable** updates to the
URL or the server, but all `useQueryState(s)` hooks will still update their
internal state and stay in sync with each other.

<Callout title="Deprecation notice">
The `throttleMs` option has been deprecated in `[email protected]` and will be removed
in a later major upgrade.

To migrate:
1. `import { throttle } from 'nuqs' {:ts}`
2. Replace `{ throttleMs: 100 }{:ts}` with `{ limitUrlUpdates: throttle(100) }{:ts}` in your options.
</Callout>

### Debounce

In addition to throttling, you can apply a debouncing mechanism to state updates,
to delay the moment where the URL gets updated with the latest value.

This can be useful for high frequency state updates where you only care about
the final value, not all the intermediary ones while typing in a search input
or moving a slider.

We recommend you opt-in to debouncing on specific state updates, rather than
defining it for the whole search param.

Let's take the example of a search input. You'll want to update it:

1. When the user is typing text, with debouncing
2. When the user clears the input, by sending an immediate update
3. When the user presses Enter, by sending an immediate update

You can see the debounce case is the outlier here, and actually conditioned on
the set value, so we can specify it using the state updater function:

```tsx
import { useQueryState, parseAsString, debounce } from 'nuqs';

function Search() {
const [search, setSearch] = useQueryState(
'q',
parseAsString.withDefault('').withOptions({ shallow: false })
)

return (
<input
value={search}
onChange={(e) =>
setSearch(e.target.value, {
// Send immediate update if resetting, otherwise debounce at 500ms
limitUrlUpdates: e.target.value === '' ? undefined : debounce(500)
})
}
onKeyPress={(e) => {
if (e.key === 'Enter') {
// Send immediate update
setSearch(e.target.value)
}
}}
/>
)
}
```

### Resetting

You can use the `defaultRateLimit{:ts}` import to reset debouncing or throttling to
the default:

```ts /defaultRateLimit/
import { debounce, defaultRateLimit } from 'nuqs'

const [, setState] = useQueryState('foo', {
limitUrlUpdates: debounce(1000)
})

// This state update isn't debounced
setState('bar', { limitUrlUpdates: defaultRateLimit })
```


## Transitions

Expand Down
67 changes: 67 additions & 0 deletions packages/e2e/next/src/app/app/debounce/client.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
'use client'

import {
debounce,
parseAsInteger,
throttle,
useQueryState,
useQueryStates
} from 'nuqs'
import { searchParams, urlKeys } from './search-params'

export function Client() {
const [timeMs, setTimeMs] = useQueryState(
'debounceTime',
parseAsInteger.withDefault(100).withOptions({
// No real need to throttle this one, but it showcases usage:
limitUrlUpdates: throttle(200)
})
)
const [{ search, pageIndex }, setSearchParams] = useQueryStates(
searchParams,
{
shallow: false,
urlKeys
}
)
return (
<>
<input
value={search}
onChange={e =>
setSearchParams(
{ search: e.target.value },
{
// Instant update when clearing the input, otherwise debounce
limitUrlUpdates:
e.target.value === '' ? undefined : debounce(timeMs)
}
)
}
onKeyDown={e => {
if (e.key === 'Enter') {
// Send the search immediately when pressing Enter
setSearchParams({ search: e.currentTarget.value })
}
}}
/>
<button onClick={() => setSearchParams({ pageIndex: pageIndex + 1 })}>
Next Page
</button>
<button onClick={() => setSearchParams(null)}>Reset</button>
<div style={{ marginTop: '1rem' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<input
type="range"
value={timeMs}
onChange={e => setTimeMs(e.target.valueAsNumber)}
step={100}
min={100}
max={2000}
/>
Search debounce time: {timeMs}ms
</label>
</div>
</>
)
}
23 changes: 23 additions & 0 deletions packages/e2e/next/src/app/app/debounce/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { type SearchParams } from 'nuqs'
import { Suspense } from 'react'
import { Client } from './client'
import { loadSearchParams } from './search-params'

type PageProps = {
searchParams: Promise<SearchParams>
}

export default async function Page({ searchParams }: PageProps) {
const { search, pageIndex } = await loadSearchParams(searchParams)
return (
<>
<h2>Server</h2>
<p>Search: {search}</p>
<p>Page Index: {pageIndex}</p>
<h2>Client</h2>
<Suspense>
<Client />
</Suspense>
</>
)
}
17 changes: 17 additions & 0 deletions packages/e2e/next/src/app/app/debounce/search-params.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import {
createLoader,
parseAsInteger,
parseAsString,
UrlKeys
} from 'nuqs/server'

export const searchParams = {
search: parseAsString.withDefault(''),
pageIndex: parseAsInteger.withDefault(0)
}
export const urlKeys: UrlKeys<typeof searchParams> = {
search: 'q',
pageIndex: 'page'
}

export const loadSearchParams = createLoader(searchParams, { urlKeys })
2 changes: 1 addition & 1 deletion packages/nuqs/src/adapters/custom.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export { renderQueryString } from '../url-encoding'
export { renderQueryString } from '../lib/url-encoding'
export {
createAdapterProvider as unstable_createAdapterProvider,
type AdapterContext as unstable_AdapterContext
Expand Down
2 changes: 1 addition & 1 deletion packages/nuqs/src/adapters/lib/context.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createContext, createElement, useContext, type ReactNode } from 'react'
import { error } from '../../errors'
import { error } from '../../lib/errors'
import type { UseAdapterHook } from './defs'

export type AdapterContext = {
Expand Down
4 changes: 2 additions & 2 deletions packages/nuqs/src/adapters/lib/patch-history.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Emitter } from 'mitt'
import { debug } from '../../debug'
import { error } from '../../errors'
import { debug } from '../../lib/debug'
import { error } from '../../lib/errors'

export type SearchParamsSyncEmitter = Emitter<{ update: URLSearchParams }>

Expand Down
4 changes: 3 additions & 1 deletion packages/nuqs/src/adapters/lib/react-router.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import mitt from 'mitt'
import { startTransition, useCallback, useEffect, useState } from 'react'
import { renderQueryString } from '../../url-encoding'
import { debug } from '../../lib/debug'
import { renderQueryString } from '../../lib/url-encoding'
import { createAdapterProvider } from './context'
import type { AdapterInterface, AdapterOptions } from './defs'
import {
Expand Down Expand Up @@ -47,6 +48,7 @@ export function createReactRouterBasedAdapter({
})
const url = new URL(location.href)
url.search = renderQueryString(search)
debug(`[nuqs ${adapter}] Updating url: %s`, url)
// First, update the URL locally without triggering a network request,
// this allows keeping a reactive URL if the network is slow.
const updateMethod =
Expand Down
4 changes: 2 additions & 2 deletions packages/nuqs/src/adapters/next/impl.app.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useRouter, useSearchParams } from 'next/navigation'
import { startTransition, useCallback, useOptimistic } from 'react'
import { debug } from '../../debug'
import { debug } from '../../lib/debug'
import type { AdapterInterface, UpdateUrlFunction } from '../lib/defs'
import { renderURL } from './shared'

Expand All @@ -16,7 +16,7 @@ export function useNuqsNextAppRouterAdapter(): AdapterInterface {
setOptimisticSearchParams(search)
}
const url = renderURL(location.origin + location.pathname, search)
debug('[nuqs queue (app)] Updating url: %s', url)
debug('[nuqs next/app] Updating url: %s', url)
// First, update the URL locally without triggering a network request,
// this allows keeping a reactive URL if the network is slow.
const updateMethod =
Expand Down
4 changes: 2 additions & 2 deletions packages/nuqs/src/adapters/next/impl.pages.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useSearchParams } from 'next/navigation.js'
import type { NextRouter } from 'next/router'
import { useCallback } from 'react'
import { debug } from '../../debug'
import { debug } from '../../lib/debug'
import { createAdapterProvider } from '../lib/context'
import type { AdapterInterface, UpdateUrlFunction } from '../lib/defs'
import { renderURL } from './shared'
Expand Down Expand Up @@ -33,7 +33,7 @@ export function useNuqsNextPagesRouterAdapter(): AdapterInterface {
// passing an asPath, causing issues in dynamic routes in the pages router.
const nextRouter = window.next?.router!
const url = renderURL(nextRouter.state.asPath.split('?')[0] ?? '', search)
debug('[nuqs queue (pages)] Updating url: %s', url)
debug('[nuqs next/pages] Updating url: %s', url)
const method =
options.history === 'push' ? nextRouter.push : nextRouter.replace
method.call(nextRouter, url, url, {
Expand Down
2 changes: 1 addition & 1 deletion packages/nuqs/src/adapters/next/shared.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { renderQueryString } from '../../url-encoding'
import { renderQueryString } from '../../lib/url-encoding'

export function renderURL(base: string, search: URLSearchParams) {
const hashlessBase = base.split('#')[0] ?? ''
Expand Down
8 changes: 5 additions & 3 deletions packages/nuqs/src/adapters/react.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import mitt from 'mitt'
import { useEffect, useState } from 'react'
import { renderQueryString } from '../url-encoding'
import { debug } from '../lib/debug'
import { renderQueryString } from '../lib/url-encoding'
import { createAdapterProvider } from './lib/context'
import type { AdapterOptions } from './lib/defs'
import type { AdapterInterface, AdapterOptions } from './lib/defs'
import { patchHistory, type SearchParamsSyncEmitter } from './lib/patch-history'

const emitter: SearchParamsSyncEmitter = mitt()

function updateUrl(search: URLSearchParams, options: AdapterOptions) {
const url = new URL(location.href)
url.search = renderQueryString(search)
debug('[nuqs react] Updating url: %s', url)
const method =
options.history === 'push' ? history.pushState : history.replaceState
method.call(history, history.state, '', url)
Expand All @@ -19,7 +21,7 @@ function updateUrl(search: URLSearchParams, options: AdapterOptions) {
}
}

function useNuqsReactAdapter() {
function useNuqsReactAdapter(): AdapterInterface {
const [searchParams, setSearchParams] = useState(() => {
if (typeof location === 'undefined') {
return new URLSearchParams()
Expand Down
6 changes: 3 additions & 3 deletions packages/nuqs/src/adapters/testing.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createElement, type ReactNode } from 'react'
import { resetQueue } from '../update-queue'
import { renderQueryString } from '../url-encoding'
import { globalThrottleQueue } from '../lib/queues/throttle'
import { renderQueryString } from '../lib/url-encoding'
import { context } from './lib/context'
import type { AdapterInterface, AdapterOptions } from './lib/defs'

Expand All @@ -25,7 +25,7 @@ export function NuqsTestingAdapter({
...props
}: TestingAdapterProps) {
if (resetUrlUpdateQueueOnMount) {
resetQueue()
globalThrottleQueue.reset()
}
const useAdapter = (): AdapterInterface => ({
searchParams: new URLSearchParams(props.searchParams),
Expand Down
Loading
Loading