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

Debounce URL updates #291

Open
nick-keller opened this issue Mar 7, 2022 · 19 comments
Open

Debounce URL updates #291

nick-keller opened this issue Mar 7, 2022 · 19 comments
Labels
feature New feature or request

Comments

@nick-keller
Copy link

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
Copy link
Member

franky47 commented Mar 7, 2022

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

@nick-keller
Copy link
Author

Alright, fair enough, thx 👍

@franky47 franky47 closed this as completed Mar 7, 2022
@franky47
Copy link
Member

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
Copy link

For debouncing:

import { useDebouncedCallback } from 'use-debounce';

...

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

@franky47
Copy link
Member

franky47 commented Feb 19, 2024

@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
Copy link

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
Copy link

Winwardo commented Mar 5, 2024

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
Copy link

shane-downes commented Mar 25, 2024

+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
Copy link
Member

franky47 commented Mar 26, 2024

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

@franky47 franky47 reopened this Mar 26, 2024
@rwieruch
Copy link

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
Copy link
Member

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
Copy link

@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
Copy link

floatrx commented Jun 26, 2024

+1 for debounce

@franky47
Copy link
Member

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.

@franky47 franky47 added the feature New feature or request label Sep 12, 2024
@hauptrolle
Copy link

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

Btw: Awesome library! ✌️

@franky47
Copy link
Member

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
Copy link
Contributor

tylersayshi commented Oct 26, 2024

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

  • There are many ways to implement debounce and they're not necessarily generalize-able
    • useDeferredValue may work for many use cases
  • The average consumer of the library may not need to debounce
  • It adds to bundle size for the average consumers who won't use it

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
Copy link
Member

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
Copy link
Contributor

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
feature New feature or request
Projects
None yet
Development

No branches or pull requests

9 participants