47ng / nuqs

Type-safe search params state manager for React frameworks - Like useState, but stored in the URL query string.
https://nuqs.47ng.com
MIT License
4.49k stars 95 forks source link

Debounce URL updates #291

Open nick-keller opened 2 years ago

nick-keller commented 2 years ago

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 ;)

franky47 commented 2 years ago

I would argue that this is not the scope of this library, there are hooks that allow debouncing and throttling methods:

nick-keller commented 2 years ago

Alright, fair enough, thx 👍

franky47 commented 1 year ago

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.

rwieruch commented 8 months ago

For debouncing:

import { useDebouncedCallback } from 'use-debounce';

...

const handleSearch = useDebouncedCallback(
  (event: ChangeEvent<HTMLInputElement>) => {
    setSearch(event.target.value);
  },
  250
);
franky47 commented 8 months ago

@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

rwieruch commented 8 months ago

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.

Winwardo commented 8 months ago

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!)

shane-downes commented 7 months ago

+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)} />
franky47 commented 7 months ago

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.
}
rwieruch commented 7 months ago

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.

franky47 commented 7 months ago

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.

greghart commented 5 months ago

@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 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];
}
floatrx commented 4 months ago

+1 for debounce

franky47 commented 4 months ago

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.

hauptrolle commented 1 week ago

+1 for debounce. Would be super helpful for a search input, to not hit the server with every keystroke :)

Btw: Awesome library! ✌️

franky47 commented 1 week ago

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!

tylersayshi commented 1 week ago

@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:

I 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.

franky47 commented 1 week ago

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).

tylersayshi commented 1 week ago

Thanks for the clarification! That makes sense to me :)