nuqs
nuqs copied to clipboard
Debounce URL updates
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 ;)
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.
https://nuqs.47ng.com/docs/options#throttling-url-updates
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. I've started refactoring this part of the app to make it more maintainable as we expand the search features.
I had actually thought of writing a state/router wrapper with a similar API to useQueryState for internal use, but found nuqs today and it seems like a great fit (awesome work btw, thank you!).
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 = getQueryString(router.query.search);
const [search, setSearch] = useQueryState('search', { debounceMs: 500 });
const { data = serverData } = 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 = serverData }] = 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.
nuqsuses 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.https://nuqs.47ng.com/docs/options#throttling-url-updates
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.