Open nick-keller opened 2 years ago
I would argue that this is not the scope of this library, there are hooks that allow debouncing and throttling methods:
Alright, fair enough, thx 👍
FYI, this was implemented in 1.8+, since changes to the history API are rate-limited by the browser, throttling was actually necessary for all updates.
For debouncing:
import { useDebouncedCallback } from 'use-debounce';
...
const handleSearch = useDebouncedCallback(
(event: ChangeEvent<HTMLInputElement>) => {
setSearch(event.target.value);
},
250
);
@rwieruch one issue with debouncing the call to setState is, like React.useState, you'd also delay the internal state representation. If using a controlled <input value={state} ...
, that will lead to the input contents lagging behind and skipping data if typing too fast.
nuqs
uses an internal React state that can be connected to high-frequency inputs (like text inputs or sliders), which updates immediately, and only throttles the updates to the URL. Combined with shallow: false
, this lets the user control how frequently the server is sent the new state URL.
I see, thanks for the nudge in the right direction! Would it make sense to have a debounceMs
too? Somehow I am used to debounce such requests rather than throttling them 😅 Throttle works too though.
Just came across this - a built in debounceMs
would be wonderful. Say I wish to debounce a search by 500ms. Using throttleMs
will mean the first character input will be sent to search, and then the rest of the typing will be throttled, which is an odd user experience.
I see that there are other ways to achieve it (e.g. use-debounce
) but if throttle is already implemented, it feels like debounce is a natural pairing.
(Thanks for the great library by the way!)
+1 for debounceMs
I'm managing some query filters and a debounced search box using router query state.
On initial testing the package works brilliantly for this - except that using throttleMs
leads to strange UX as Winwardo noted above.
I think debounceMs
would be a great fit for the package because of how well throttle almost works out of the box for this use case.
Potential example with debounceMs
(Pages router / RTKQ):
const searchQuery = router.query.search;
const [search, setSearch] = useQueryState('search', { debounceMs: 500 });
const { data } = useGetDataQuery({ search: searchQuery }); // RTKQ
//...
<input value={search} onChange={(e) => setSearch(e.target.value)} />
Example without debounceMs
:
const [search, setSearch] = useQueryState('search');
// Have to manually control query here
const [getData, { data }] = useLazyGetDataQuery();
useEffect(() => {
const debounce = setTimeout(() => {
getData({ search });
}, 500);
return () => {
clearTimeout(debounce);
};
}, [getData, search]);
//...
<input value={search} onChange={(e) => setSearch(e.target.value)} />
I see two issues with supporting both throttling and debouncing.
The first is the API: providing both throttleMs
and debounceMs
would allow setting both, which results in undefined behaviour. This could be solved by changing the API to something like this (feedback, ideas and suggestions welcome):
.withOptions({
limitUrlUpdates: { // better be explicit in what this does
method: 'throttle' | 'debounce',
timeMs: 500
}
})
// Or with helpers:
.withOptions({
limitUrlUpdates: debounce(500),
limitUrlUpdates: throttle(500)
})
The second point is closely related: both throttle and debounce methods will actually run in parallel, for different hook setups to work together. Example:
const [, setFoo] = useQueryState('foo', { limitUrlUpdates: debounce(500) })
const [, setBar] = useQueryState('bar', { limitUrlUpdates: throttle(500) })
const doubleUpdate = () => {
setFoo('foo') // This will update in 500ms
setBar('bar') // This will update immediately
}
I'll see what I can do to refactor the URL update queue system to account for both methods, but this will conflict with the Promise returned by the state updater functions being cached until the next update. Not sure how big a deal this is: hooks set to throttle will return one Promise, hooks set to debounce will return another.
If two hooks are set to debounce with different times, just like throttle, the largest one wins:
const [, setA] = useQueryState('a', { limitUrlUpdates: debounce(200) })
const [, setB] = useQueryState('b', { limitUrlUpdates: debounce(100) })
const doubleUpdate = () => {
setA('a')
setB('b')
// Both will be applied in 200ms if there are no other updates.
}
Hm. Will the next version be a breaking change anyway? One could consider only supporting debounce and not throttle anymore. But one would have to get some user data here whether debounce is more widely used for URL state.
I believe both methods are justified, it's not a deal breaker, just a bit of refactoring work on the internals.
As for the breaking change part, yes this would probably land in v2, or it could be done in a non-breaking way by deprecating the throttleMs
option and let limitUrlUpdates
take precedence if both are defined, to resolve conflicts.
I would still keep throttling as the default, as it is more reactive and predictable. Before shallow routing was introduced, the delayed update of the URL was a side effect of the network call to the server to update RSCs, and it looked sluggish and people complained.
@rwieruch one issue with debouncing the call to setState is, like React.useState, you'd also delay the internal state representation. If using a controlled
<input value={state} ...
, that will lead to the input contents lagging behind and skipping data if typing too fast.
nuqs
uses an internal React state that can be connected to high-frequency inputs (like text inputs or sliders), which updates immediately, and only throttles the updates to the URL. Combined withshallow: false
, this lets the user control how frequently the server is sent the new state URL.
I appreciate this getting looked at!
In the mean time, you can still get debouncing with high-frequency inputs by using useDebounce
(or lodash/debounce
if you like the leading/trailing options) alongside useState
in a little hook wrapper like so:
(note I didn't type all the overloads since I don't use them so YMMV)
function useQueryStateDebounced<T>(
key: string,
options: UseQueryStateOptions<T> & {
defaultValue: T;
},
debounceMs = 350
): UseQueryStateReturn<
NonNullable<ReturnType<typeof options.parse>>,
typeof options.defaultValue
> {
const [valueQs, setQs] = nuqsUseQueryState<T>(key, options);
const [valueReact, setReact] = React.useState<T | null>(valueQs);
const debouncedSetQs = React.useCallback(debounce(setQs, debounceMs), [
setQs,
]);
const set = (newValue: any) => {
setReact(newValue);
debouncedSetQs(newValue);
};
return [valueReact as typeof valueQs, set as typeof setQs];
}
+1 for debounce
I'm planning on working on this during the summer holidays, if I can find a bit of free time. It also depends on the Next.js 15 / React 19 release schedule.
+1 for debounce. Would be super helpful for a search input, to not hit the server with every keystroke :)
Btw: Awesome library! ✌️
To avoid sending on every keystroke, you can use the throttleMs
option, but yeah doing an eventually consistent send with the whole query after a certain time of inactivity would be preferable.
Now that v2 is out, debounce support is back on the roadmap!
@TkDodo talked at react advance yesterday a little about why react-query chose to not implement debouncing in the core library itself: youtube link
The key points from what I could tell:
useDeferredValue
may work for many use casesI don't personally have strong opinions one way or the other, but react-query's experience with debounce
as a potential feature seems very relevant here.
Thanks for the link @tylersayshi !
One key difference here I think, is that in the example given for RQ, the thing to rate-limit is truly external to the library: it's an input passed to the query key and to other options. In this case, composition is indeed a much better API.
The difference in nuqs is that the thing to rate-limit is not exposed to the user: we're not talking about rate-limiting updates of the state value returned by the hook (this can, and should, be done in userland like for RQ), but rate-limiting the sync mechanism that writes to the URL, because of limits imposed by browser vendors.
Since those updates occur outside of the render tree, traditional composition (via hooks) doesn't apply. We could allow some sort of callback mechanism to define custom methods of rate-limiting, but those likely would be less optimised and would not handle certain cases (eg: setting multiple query states in the same tick batches them together into a single URL update).
Thanks for the clarification! That makes sense to me :)
Hello, For now, it cannot be used to save the state of a search input because the URL is updated for each key stroke. It would be nice to debounce or throttle the URL updates for a smoother interaction ;)