Skip to content

Commit b18f5e6

Browse files
authored
feat: Debounce (#900)
1 parent 8458d55 commit b18f5e6

File tree

220 files changed

+4104
-1413
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

220 files changed

+4104
-1413
lines changed

packages/docs/content/docs/options.mdx

Lines changed: 116 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -130,43 +130,145 @@ useQueryState('foo', { scroll: true })
130130
```
131131

132132

133-
## Throttling URL updates
133+
## Rate-limiting URL updates
134134

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

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

143-
If you want to opt-in to a larger throttle time -- for example to reduce the amount
143+
<Callout title="Note">
144+
the state returned by the hook is always updated **instantly**, to keep UI responsive.
145+
Only changes to the URL, and server requests when using `shallow: false{:ts}`, are throttled.
146+
</Callout>
147+
148+
This throttle time is configurable, and also allows you to debounce updates
149+
instead.
150+
151+
<Callout title="Which one should I use?">
152+
Throttle will emit the first update immediately, then batch updates at a slower
153+
pace **regularly**. This is recommended for most low-frequency updates.
154+
155+
Debounce will push back the moment when the URL is updated when you set your state,
156+
making it **eventually consistent**. This is recommended for high-frequency
157+
updates where the last value is more interesting than the intermediate ones,
158+
like a search input or moving a slider.
159+
160+
Read more about [debounce vs throttle](https://kettanaito.com/blog/debounce-vs-throttle).
161+
</Callout>
162+
163+
### Throttle
164+
165+
If you want to increase the throttle time -- for example to reduce the amount
144166
of requests sent to the server when paired with `shallow: false{:ts}` -- you can
145-
specify it under the `throttleMs` option:
167+
specify it under the `limitUrlUpdates` option:
146168

147-
```tsx
148-
// [!code word:throttleMs]
169+
```ts /limitUrlUpdates/
149170
useQueryState('foo', {
150171
// Send updates to the server maximum once every second
151172
shallow: false,
152-
throttleMs: 1000
173+
limitUrlUpdates: {
174+
method: 'throttle',
175+
timeMs: 1000
176+
}
153177
})
154-
```
155178

156-
<Callout title="Note">
157-
the state returned by the hook is always updated **instantly**, to keep UI responsive.
158-
Only changes to the URL, and server requests when using `shallow: false{:ts}`, are throttled.
159-
</Callout>
179+
// or using the shorthand:
180+
import { throttle } from 'nuqs'
181+
182+
useQueryState('foo', {
183+
shallow: false,
184+
limitUrlUpdates: throttle(1000)
185+
})
186+
```
160187

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

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

197+
<Callout title="Deprecation notice">
198+
The `throttleMs` option has been deprecated in `nuqs@2.4.0` and will be removed
199+
in a later major upgrade.
200+
201+
To migrate:
202+
1. `import { throttle } from 'nuqs' {:ts}`
203+
2. Replace `{ throttleMs: 100 }{:ts}` with `{ limitUrlUpdates: throttle(100) }{:ts}` in your options.
204+
</Callout>
205+
206+
### Debounce
207+
208+
In addition to throttling, you can apply a debouncing mechanism to state updates,
209+
to delay the moment where the URL gets updated with the latest value.
210+
211+
This can be useful for high frequency state updates where you only care about
212+
the final value, not all the intermediary ones while typing in a search input
213+
or moving a slider.
214+
215+
We recommend you opt-in to debouncing on specific state updates, rather than
216+
defining it for the whole search param.
217+
218+
Let's take the example of a search input. You'll want to update it:
219+
220+
1. When the user is typing text, with debouncing
221+
2. When the user clears the input, by sending an immediate update
222+
3. When the user presses Enter, by sending an immediate update
223+
224+
You can see the debounce case is the outlier here, and actually conditioned on
225+
the set value, so we can specify it using the state updater function:
226+
227+
```tsx
228+
import { useQueryState, parseAsString, debounce } from 'nuqs';
229+
230+
function Search() {
231+
const [search, setSearch] = useQueryState(
232+
'q',
233+
parseAsString.withDefault('').withOptions({ shallow: false })
234+
)
235+
236+
return (
237+
<input
238+
value={search}
239+
onChange={(e) =>
240+
setSearch(e.target.value, {
241+
// Send immediate update if resetting, otherwise debounce at 500ms
242+
limitUrlUpdates: e.target.value === '' ? undefined : debounce(500)
243+
})
244+
}
245+
onKeyPress={(e) => {
246+
if (e.key === 'Enter') {
247+
// Send immediate update
248+
setSearch(e.target.value)
249+
}
250+
}}
251+
/>
252+
)
253+
}
254+
```
255+
256+
### Resetting
257+
258+
You can use the `defaultRateLimit{:ts}` import to reset debouncing or throttling to
259+
the default:
260+
261+
```ts /defaultRateLimit/
262+
import { debounce, defaultRateLimit } from 'nuqs'
263+
264+
const [, setState] = useQueryState('foo', {
265+
limitUrlUpdates: debounce(1000)
266+
})
267+
268+
// This state update isn't debounced
269+
setState('bar', { limitUrlUpdates: defaultRateLimit })
270+
```
271+
170272

171273
## Transitions
172274

packages/docs/package.json

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,6 @@
5252
"recharts": "^2.15.2",
5353
"remark-smartypants": "^3.0.2",
5454
"res": "workspace:*",
55-
"semver": "^7.7.1",
5655
"server-only": "^0.0.1",
5756
"tailwind-merge": "^2.6.0",
5857
"tailwindcss": "^3.4.17",
@@ -64,7 +63,6 @@
6463
"@types/mdx": "^2.0.13",
6564
"@types/react": "catalog:react19",
6665
"@types/react-dom": "catalog:react19",
67-
"@types/semver": "^7.7.0",
6866
"autoprefixer": "^10.4.21",
6967
"hast-util-to-jsx-runtime": "^2.3.6",
7068
"postcss": "^8.5.3",

packages/e2e/next/cypress.config.ts

Lines changed: 1 addition & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,11 @@
11
import { defineConfig } from 'e2e-shared/cypress.config'
2-
import fs from 'node:fs'
3-
import semver from 'semver'
42

53
const basePath =
64
process.env.BASE_PATH === '/' ? '' : (process.env.BASE_PATH ?? '')
75

8-
const nextJsVersion = readNextJsVersion()
9-
106
export default defineConfig({
117
baseUrl: `http://localhost:3001${basePath}`,
128
env: {
13-
basePath,
14-
supportsShallowRouting: supportsShallowRouting(nextJsVersion),
15-
nextJsVersion
9+
basePath
1610
}
1711
})
18-
19-
function readNextJsVersion() {
20-
const pkgPath = new URL('./node_modules/next/package.json', import.meta.url)
21-
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'))
22-
return pkg.version
23-
}
24-
25-
function supportsShallowRouting(nextVersion: string) {
26-
return semver.gte(nextVersion, '14.1.0')
27-
}

packages/e2e/next/cypress/e2e/persist-across-navigation.cy.js renamed to packages/e2e/next/cypress/e2e/persist-across-navigation.cy.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,12 @@
1-
/// <reference types="cypress" />
1+
import { expectPathname } from 'e2e-shared/lib/assertions'
22

33
it('Persists search params across navigation using a generated Link href', () => {
44
cy.visit('/app/persist-across-navigation/a')
55
cy.contains('#hydration-marker', 'hydrated').should('be.hidden')
66
cy.get('input[type=text]').type('foo', { delay: 0 })
77
cy.get('input[type=checkbox]').check()
88
cy.get('a').click()
9-
cy.location('pathname').should(
10-
'eq',
11-
`${Cypress.env('basePath')}/app/persist-across-navigation/b`
12-
)
9+
expectPathname('/app/persist-across-navigation/b')
1310
cy.location('search').should('eq', '?q=foo&checked=true')
1411
cy.get('input[type=text]').should('have.value', 'foo')
1512
cy.get('input[type=checkbox]').should('be.checked')

packages/e2e/next/cypress/e2e/repro-359.cy.js

Lines changed: 0 additions & 52 deletions
This file was deleted.

packages/e2e/next/cypress/e2e/repro-388.cy.js

Lines changed: 0 additions & 35 deletions
This file was deleted.

packages/e2e/next/cypress/e2e/repro-498.cy.js

Lines changed: 0 additions & 16 deletions
This file was deleted.

packages/e2e/next/cypress/e2e/repro-542.cy.js

Lines changed: 0 additions & 14 deletions
This file was deleted.

packages/e2e/next/cypress/e2e/repro-630.cy.js

Lines changed: 0 additions & 39 deletions
This file was deleted.

0 commit comments

Comments
 (0)