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

Cannot debounce API calls #293

Closed
basslagter opened this issue Mar 27, 2020 · 38 comments
Closed

Cannot debounce API calls #293

basslagter opened this issue Mar 27, 2020 · 38 comments

Comments

@basslagter
Copy link

I am trying to use this for a search component, like:

  const { status, data, error, isFetching } = useQuery(
    searchQuery && ['results', searchQuery],
    getSearchAsync,
  );

I would like the call to getSearchAsync to be debounced while the user is typing the query. Tried adding lodash.debounce like:

  const { status, data, error, isFetching } = useQuery(
    searchQuery && ['results', searchQuery],
    debounce(getSearchAsync, 3000),
  );

But since this is a function component it is not working as expected. The getSearchAsync method is only called multple times after the timeout expires, which I expect to be once with the latest value (searchQuery).

@tannerlinsley
Copy link
Collaborator

You need to use a debounce that supports promises. It needs to return a promise immediately, then debounce off of that promise.

@basslagter
Copy link
Author

@tannerlinsley thanks for the reply. Can you give an example?

@tannerlinsley
Copy link
Collaborator

https://github.com/tannerlinsley/react-table/blob/master/src/publicUtils.js

@basslagter
Copy link
Author

Wow...there is no debounce package that does this?
Or even better...can't react-query have an option for this?

@denisborovikov
Copy link
Contributor

You may try to debounce searchQuery change instead, something like that.

@basslagter
Copy link
Author

@denisborovikov thanks...that works nicely!
Still...wouldn't it be nice to have this as an option in react-query?

@timkindberg
Copy link

timkindberg commented May 4, 2020

I'd like to see this, since react-query is serializing the key it's doing all the heavy lifting there, and could then debounce the result of the key (which devs have no hook into that result).

Can this be reopened?

@mporkola
Copy link

@tannerlinsley could this be reconsidered? Debouncing based on the serialized query key would be an elegant solution, and in my view a reasonable responsibility for react-query. I'm interested to try my hand in making a PR if it has any chance of getting merged.

@tbntdima
Copy link

For me, if I type very fast I cannot see the input. Only after I finish typing I see the text. After that, all works fine.
Here is a my code:

const [searchTerm, setSearchTerm] = useState('')
const debouncedSearchTerm = useDebounce(searchTerm, 500)
const { data } = useSearch({ searchTerm: debouncedSearchTerm })

@denisborovikov
Copy link
Contributor

@tbntdima If you use searchTerm as your input's value prop you should see as you type. You probably use debouncedSearchTerm instead and that's why the input is denounced. Debounced value should be used for the query only.

@tbntdima
Copy link

tbntdima commented Sep 14, 2020

@denisborovikov the problem is a bit different. When searchTerm changes, the component was re-rendering anyway or at least tried to. And because of this, I was getting lags. My eventual solution was to move the denounce login into the upper component 🤔

@kripod
Copy link
Contributor

kripod commented Jan 13, 2021

Another issue seems to arise when dealing with debouncers. Debounced Promise objects get stuck in a "loading" state within React Query and stay inside MutationCache forever.

@plbertrand
Copy link

I'm fairly new to React Query but I haven't figured out how to serialize mutations to the same entry. It's necessary in the case of updating an input and avoid race conditions of two update queries. Am I wrong in thinking that this should be handled by React Query? Whatever I search to that end resolves here but even debouncing has this race condition, just less likely to happen. Is there a configuration for serializing updates? How about buffering updates while there is one in progress?

@GeekEast
Copy link

I probably found something very similar to react-query but with debounce built in.

https://ahooks.js.org/hooks/use-request/debounce

@Noitidart
Copy link

https://github.com/tannerlinsley/react-table/blob/master/src/publicUtils.js

Does anyone have reference to this doubnce util that supports promises?

@mrlubos
Copy link

mrlubos commented Sep 4, 2022

@Noitidart you might want to look at <DebouncedInput /> or if you're after the original code, it's called useAsyncDebounce()

@Noitidart
Copy link

Thanks @mrlubos! I actually did something very interesting. I used initialData to store the debounced function (simple debounce with lodash) inside the data. I then make the queryFn call the method inside data. I then store the returned data in a nested key in data. Funky, but works very smoothly, it respects the react-query useQuery cache.

@roccomaniscalco
Copy link

roccomaniscalco commented Sep 6, 2022

For anyone who is interested in debouncing a mutation, I hacked together this hook. It returns a mutation with the debouncedMutate function which accepts debounceMs as an option. Apologies for no typescript.

const useDebouncedMutation = (mutationFn, options) => {
  const mutation = useMutation(mutationFn, options);
  const [isDebouncing, setIsDebouncing] = useState(false);
  const timer = useRef();

  const debouncedMutate = (variables, { debounceMs, ...options }) => {
    clearTimeout(timer.current);
    setIsDebouncing(true);
    timer.current = setTimeout(() => {
      mutation.mutate(variables, options);
      setIsDebouncing(false);
    }, debounceMs);
  };

  return { isDebouncing, debouncedMutate, ...mutation };
};

Example use:

const channelExists = useDebouncedMutation(api.doesChannelExist);
channelExists.debouncedMutate(channelName, {
  debounceMs: 500,
});

I'm using this hook in a chat app to check if a user inputted channel name already exists.

Edit: As pointed out by @jjorissen52, timer should be a ref so that it is not reinitialized on each render. I have updated useDebouncedMutation accordingly 🤙

@lciii
Copy link

lciii commented Oct 5, 2022

https://github.com/tannerlinsley/react-table/blob/master/src/publicUtils.js

FYI This link doesn't work anymore

@mrlubos
Copy link

mrlubos commented Oct 5, 2022

Hey @lciii, the library has gone through numerous changes over the years, so the links pointing to the then-current version no longer work. You can find the file you referenced in my earlier comment above

@ftzi
Copy link

ftzi commented Oct 28, 2022

Would really be cool to have a debounceMs option in useMutation, with default value = 0. It would be a really good ready to use functionality. +1

@AndreSilva1993
Copy link

AndreSilva1993 commented Nov 7, 2022

Found this thread since I have the same issue and debouncing the change does not work for me. Let's imagine the scenario in which I debounce the change for 250ms.

  • I select item A and wait for 250ms. An API request to fetch the item details is fired
  • I select item B.
  • I select item C without waiting 250ms
  • I select item D without waiting 250ms.

During the selection of item B, C and D I'm still showing the details of item A since I'm debouncing its change. So I wanted to debounce the fetch request since I want to display the loading spinner indicating that the current item is being loaded but not fire the request immediately. Otherwise the user does not have an immediate feedback that something is actually happening.

I tested and I reckon this works but wanted to know your thoughts!

import { useRef } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';

import type {
  QueryKey,
  QueryFunction,
  UseQueryOptions,
} from '@tanstack/react-query';

/**
 * This is the way we can use the UseQueryOptions so that we maintain the useQuery API
 * and at the same time maintain the types for the return of the queryFn, otherwise the
 * data property would default to unknown.
 */
interface UseDebouncedQueryOptions<TQueryData>
  extends UseQueryOptions<TQueryData> {
  queryFn: QueryFunction<TQueryData>;
}

export function useDebouncedQuery<TQueryData>(
  {
    queryFn,
    queryKey,
    ...remainingUseQueryOptions
  }: UseDebouncedQueryOptions<TQueryData>,
  debounceMs: number,
) {
  const timeoutRef = useRef<number>();
  const queryClient = useQueryClient();
  const previousQueryKeyRef = useRef<QueryKey>();

  return useQuery({
    ...remainingUseQueryOptions,
    queryKey,
    queryFn: (queryFnContext) => {
      // This means the react-query is retrying the query so we should not debounce it.
      if (previousQueryKeyRef.current === queryKey) {
        return queryFn(queryFnContext);
      }

      /**
       * We need to cancel previous "pending" queries otherwise react-query will give us an infinite
       * loading state for this key since the Promise we returned was neither resolved nor rejected.
       */
      if (previousQueryKeyRef.current) {
        queryClient.cancelQueries({ queryKey: previousQueryKeyRef.current });
      }

      previousQueryKeyRef.current = queryKey;
      window.clearTimeout(timeoutRef.current);

      return new Promise((resolve, reject) => {
        timeoutRef.current = window.setTimeout(async () => {
          try {
            const result = await queryFn(queryFnContext);

            previousQueryKeyRef.current = undefined;
            resolve(result as TQueryData);
          } catch (error) {
            reject(error);
          }
        }, debounceMs);
      });
    },
  });
}

useDebouncedQuery(250, {
  queryKey: ['foo', fooId],
  queryFn: /* the fetch function that returns a Promise */
);

The cancelQueries should avoid the infinite loading state due to the Promises never being either resolved or rejected.

@vincerubinetti
Copy link

vincerubinetti commented Nov 9, 2022

Just want to say... I know it's not a deal-breaker to use debounced versions of query key variables, but you could also say that about implementing caching, or pagination, or one of the seemingly many many other use/edge cases that React Query covers. It's kind of weird that RQ chooses to cover all that other complex and niche stuff, but not this very common (as you can tell by all the 👍s) need. This issue shouldn't be dismissed.

@TkDodo
Copy link
Collaborator

TkDodo commented Nov 9, 2022

@AndreSilva1993 I think the solution could be simpler. Unless I'm mistaken, if you just want to know if something has been selected that hasn't started fetching yet because it's debounced, all you'd need is to compare selections:

const [selected, setSelected] = useState('')
const debouncedSelected = useDebounce(selected, 250)
const { data } = useQuery({
  queryKey: ['key', debouncedSelected],
  queryFn: () => fetchData(debouncedSelected)
})

const isTransitioning = selected !== debouncedSelected

now you can check for isTransitioning to show a loading spinner instead of data for item A (debouncedSelected), because you've selected itemB/C/D (selected) instead.

@TkDodo
Copy link
Collaborator

TkDodo commented Nov 9, 2022

@vincerubinetti I get that this a feature requested by many, but there are always tradeoffs:

  • bundle size vs. features
  • api surface vs. simplicity

react-query does not have the smallest bundle size and not the most simplistic api surface. The stance is that features that can be implemented in user-land with minimal effort will not make it into the library. Interestingly, from the things you've mentioned: react-query doesn't do anything for "pagination". The pagination uses a feature that was meant to mimic suspense/transitions (keepPreviousData). Unless you're thinking of inifinte queries, which is something that isn't easily doable in user-land with raw useQuery.

Now for debouncing, the problems are:

  • we'd need to implement debouncing or add an external dependency. right now, we have zero dependencies other than the query-core, which is our own code. lodash/debounce adds another kilobyte, about 10% of the total lib size. I know bundle-size is an argument that I personally dislike, too, but it's something that we sadly have to keep in mind.
  • I haven't thought this through, but I think internal debounce is not easily compatible with how react-query works. consider the example from right above:
const [selected, setSelected] = useState('')
const { data } = useQuery({
  queryKey: ['key', setSelected],
  queryFn: () => fetchData(setSelected),
  // hypothetical api, doesn't exist
  debounce: 250
})

now, the queryKey would change with every input, but we wouldn't want to start observing that key (switching to that queryObserver) before debounce time. That also means that all other options changes should not be applied, because they "belong to" the new selection key.
So I think this opens up a can of worms for no real gain. Lots of complexity and edge cases within the library. Basically, a key change doesn't / shouldn't do anything if debounce is specified. That is hard to justify and explain.

@AndreSilva1993
Copy link

@AndreSilva1993 I think the solution could be simpler. Unless I'm mistaken, if you just want to know if something has been selected that hasn't started fetching yet because it's debounced, all you'd need is to compare selections:

const [selected, setSelected] = useState('')
const debouncedSelected = useDebounce(selected, 250)
const { data } = useQuery({
  queryKey: ['key', debouncedSelected],
  queryFn: () => fetchData(debouncedSelected)
})

const isTransitioning = selected !== debouncedSelected

now you can check for isTransitioning to show a loading spinner instead of data for item A (debouncedSelected), because you've selected itemB/C/D (selected) instead.

Ok, I was definitely over-complicating things. I think your example more than suffices for what I'm trying to accomplish.

@AndreSilva1993
Copy link

AndreSilva1993 commented Nov 9, 2022

@TkDodo Only caveat I've now realised with the solution you provided (should be a question of preference and will definitely ponder on this) is that even if react-query has already fetched the results for a specific item, I'll always display the loading spinner for the amount of time I'm debouncing items whereas, if the fetch is the one being debounced but the queryKey changes immediately, I get the cached results straight away.

So my question is, if we decide to go with the custom hook do you see any evident issues or pitfalls that I might run into? And thanks for your time by the way.

@vincerubinetti
Copy link

@TkDodo Thank you for your comment. I think it's important for a maintainer to weigh in and explain why something is "wont-fix". Also maybe the examples I gave were bad, but as you alluded to, RQ has a relatively complex api with a lot of options and features, which was my overall point.

From your explanation, I'm assuming that the "debouncing the serialized key" suggestion above falls prey to the same disproportionate increase in complexity you mentioned? Hopefully that idea has been adequately considered by the team too.

@ftzi
Copy link

ftzi commented Nov 9, 2022

I've been using use-debounce since I commented here 2 weeks ago and it works great (lodash one didn't seem too good, maybe as it's not a proper tool for react?).

Still, I would like it if react-query had plugins to be optionally installed, so they wouldn't add additional weight for common users but only for those who opt-in for that. I don't know much about this topic so I can't go further on it, but this is my conclusion about this.

Edit: Or maybe at least add those solutions to the Docs so they don't appear hacky to bosses?

@TkDodo
Copy link
Collaborator

TkDodo commented Nov 11, 2022

From your explanation, I'm assuming that the "debouncing the serialized key" suggestion above falls prey to the same disproportionate increase in complexity you mentioned? Hopefully that idea has been adequately considered by the team too.

Pretty much, yes. You would have to debounce the whole options:

useQuery({
  queryKey: ['key', input],
  queryFn: () => fetchData(input),
  debounce: 500,
  refetchInterval: input === 0 ? 0 : 5000
})

by "only" debouncing the key, we would put a refetchInterval on the input with value 0 if we transition from 0 to 1 for example. We would have to debounce everything, which is literally what debouncing the input in userland is.

I'll always display the loading spinner for the amount of time I'm debouncing items whereas, if the fetch is the one being debounced but the queryKey changes immediately, I get the cached results straight away.

Yeah that's true, it's a general tradeoff when debouncing the queryKey. But it's the same issue as written above - I don't think we can easily just debounce the key or the queryFn, we need to debounce everything.

Don't want to sunshine the tradeoff, but I think it usually doesn't matter much to have a bunch of millis delay in rendering, and it might even be a good thing. If I'm typing hello world and I have results in the cache for hello and hello w and hello wo, I would get 3 intermediate renders with data I'm not really interested at. You might also need to go into concurrent features then to make those renders smooth.

Edit: Or maybe at least add those solutions to the Docs so they don't appear hacky to bosses?

absolutely, PRs do the docs are always welcome ❤️

@jcdb95
Copy link

jcdb95 commented Dec 28, 2022

If you are here looking for some Vue answer, I used the watchDebounced from vueUse and it looks something like this:

const rates = reactive(
  useQuery(
    ['rates', someDynamicKey],
     () => {
      return api.someApiCall()
    },
    {
      retry: 0,
      refetchOnWindowFocus: false,
      enabled: false
    }
  )
);

watchDebounced(
  rateData,
  () => {
    rates.refetch();
  },
  { debounce: 500, deep: true, immediate: true }
);

Hope it helps!

@julioflima
Copy link

I've been using use-debounce since I commented here 2 weeks ago and it works great (lodash one didn't seem too good, maybe as it's not a proper tool for react?).

Still, I would like it if react-query had plugins to be optionally installed, so they wouldn't add additional weight for common users but only for those who opt-in for that. I don't know much about this topic so I can't go further on it, but this is my conclusion about this.

Edit: Or maybe at least add those solutions to the Docs so they don't appear hacky to bosses?

Best solution ever, no one posted the safety lib to do the process. Of course this should be in the production by default.

Simple case super recurrent:
You gonna receive a parameter from the url to mutate when the user load the time for the first time. In the React 18, the useEffect is fetched two times, and this couldn't be done in the ServerComponents.

@thefaded
Copy link

thefaded commented Feb 7, 2023

You don't really need any of React hooks to handle debounce, here's an example with old version of axios.

const debouncedFetcher = (url: string, params?: any, signal?: AbortSignal) => {
  const CancelToken = axios.CancelToken
  const source = CancelToken.source()
  /** Wrap your fetcher with Promise and resolve on timeout */
  const promise = new Promise((resolve) => setTimeout(resolve, 300)).then(() => fetcher(url!, params, source.token))

  signal?.addEventListener("abort", () => {
    source.cancel("Query was cancelled by React Query")
  })

  return promise
}

@hitsthings
Copy link

hitsthings commented Mar 23, 2023

I'm here because I need a debounced mutate in a way it doesn't seem anyone else does. I'm using a mutation to save changes to a prose document. Since the user can be typing very fast, I only update once per second.

I'm looking for a debounced mutation that interacts well with optimistic queries. So the version above at #293 (comment) doesn't suffice.

I'd like to be able to do:

const mutate = useMutation(somethingExpensive, {
    onMutate: ...set queries to optimistic value...,
    onError: ...revert queries...,
    onSettled: ...invalidate queries so they refetch...,
    debounce: 1000
})

And have onMutate called immediately so that the optimistic value is shown to the user without a delay.

The snippet linked wouldn't show the optimistic value until 1s later.

This is also a non-trivial feature to implement because if you implement it within your query function, the mutation calls can pile up - do you really want that? There's no good response to the intermediate queries that never actually ran. It's not an error, but it's also not success.

And if you implement it outside of useMutation, you've got to reimplement/mess with your own onMutate and onSettled for optimistic queries.

Here's roughly what I have now. Call it like:

const mutate = useMyModifiedMutation(fn, { debounce: 1000, onMutate: ... onSettled: ... })

Implemented like (not exactly - paraphrased, hopefully still works after the paraphrasing):

function useMyModifiedMutation(fn, opts) {
    const queryClient = useQueryClient()
    const _onMutate = options.onMutate
    let mutationFn, onMutate, onSettled = opts.onSettled
    const fnRef = useRef(fn)
    fnRef.current = fn
    const [debouncedFn, onSettledDebounced = useMemo(() => opts.debounce
        ? [
            debounceP(args => fnRef.current(args), opts.debounce),
            onSettled && debounce(onSettled, 10) // otherwise you get all the onSettled's happening at once
        ]
        : [],
        [opts.debounce]
    )
    if (debouncedFn) {
        let debouncedContext
        mutationFn = _onMutate
            ? async (vars) => {
                const ret = debouncedFn(vars)
                // debouncedContext is always reset to the newest version of itself
                 _onMutate && (debouncedContext = await _onMutate(vars))
                return ret
            }
            : debouncedFn
        onMutate = _onMutate && (vars) => { // no-op, this has already happened.
            //just need to return the context we've been using
            return debouncedContext
        }
        onSettled = onSettledDebounced
    } else {
        onMutate = _onMutate
        mutationFn = fn
    }
    return useMutation({
        ...opts,
        onMutate,
        mutationFn
    })
}

where debounceP is

const getResolvable = <T>() => {
    let res : (p:Promise<T>) => void, p = new Promise<T>((resolve) => {
        res = resolve
    })
    return [res!, p] as const
}
const debounceP = <Args extends any[], Ret>(fn: (...args:Args) => Promise<Ret>, ms: number) : ((...args:Args) => Promise<Ret>) => {
    let handle: number | NodeJS.Timeout
    let [res, p] : readonly [null|((p:Promise<Ret>) => void), null|Promise<Ret>] = [null, null]
    const debounced = (...args: Args) => {
        if (res == null) {
            ([res, p] = getResolvable<Ret>() )
        }
        clearTimeout(handle)
        handle = setTimeout(() => {
            res && res(fn(...args))
            res = p = null
        }, ms)
        return p!
    }
    return debounced
}

And debounce is similar with out the Promise stuff:

const debounce = <Args extends any[]>(fn: (...args: Args) => void, ms: number) => {
    let handle: number | NodeJS.Timeout
    const debounced = (...args: Args) => {
        clearTimeout(handle)
        handle = setTimeout(() => fn(...args), ms)
    }
    return debounced
}

The big gotcha to this implementation is that intermediate mutations are thrown away! So you have to be sure that the latest mutation always includes previous ones as well (which is true in my case, based on the nature of text editing).

Hope this helps someone else!

UPDATE: And of course I'm already noticing things like the "old" context is now wrong in my onMutate. So onError, the value will be reset only back to the last update, not the last successful update. Definitely wish this were part of the library :)

@jjorissen52
Copy link

jjorissen52 commented Feb 13, 2024

For anyone who is interested in debouncing a mutation, I hacked together this hook. It returns a mutation with the debouncedMutate function which accepts debounceMs as an option. Apologies for no typescript.

const useDebouncedMutation = (mutationFn, options) => {
  const mutation = useMutation(mutationFn, options);
  const [isDebouncing, setIsDebouncing] = useState(false);
  let timer;

  const debouncedMutate = (variables, { debounceMs, ...options }) => {
    clearTimeout(timer);
    setIsDebouncing(true);
    timer = setTimeout(() => {
      mutation.mutate(variables, options);
      setIsDebouncing(false);
    }, debounceMs);
  };

  return { isDebouncing, debouncedMutate, ...mutation };
};

Example use:

const channelExists = useDebouncedMutation(api.doesChannelExist);
channelExists.debouncedMutate(channelName, {
  debounceMs: 500,
});

I'm using this hook in a chat app to check if a user inputted channel name already exists.

This implementation does not always debounce. Though the debouncedMutate closes over the context containing timer, the block let timer will execute every time the component re-renders, effectively calling clearTimeout(null) and failing to clear the timeout. Here is a modified version that will debounce even between parent component renders:

type DebouncedMutate<TData = unknown, TError = DefaultError, TVariables = void, TContext = unknown> = (
  variables: TVariables,
  { debounceMs, ...options }: UseMutationOptions<TData, TError, TVariables, TContext> & { debounceMs: number }
) => void
type UseDebouncedMutationReturn<TData = unknown, TError = DefaultError, TVariables = void, TContext = unknown> = Omit<
  UseMutationResult<TData, TError, TVariables, TContext>,
  'data' | 'mutate'
> & { debouncedMutate: DebouncedMutate<TData, TError, TVariables, TContext> }

export function useDebouncedMutation<TData = unknown, TError = DefaultError, TVariables = void, TContext = unknown>(
  options: UseMutationOptions<TData, TError, TVariables, TContext>
): UseDebouncedMutationReturn<TData, TError, TVariables, TContext> {
  const { mutate, ...mutation } = useMutation<TData, TError, TVariables, TContext>(options)
  const timer = useRef<NodeJS.Timeout>()
  const debouncedMutate: DebouncedMutate<TData, TError, TVariables, TContext> = (
    variables,
    { debounceMs, ...options }
  ) => {
    clearTimeout(timer.current)
    timer.current = setTimeout(() => {
      mutate(variables, options)
    }, debounceMs)
  }
  return { debouncedMutate, ...mutation }
}

Saving the Timeout with a ref means it will be available between renders.

@nickgrealy
Copy link

You don't really need any of React hooks to handle debounce, here's an example with old version of axios.

const debouncedFetcher = (url: string, params?: any, signal?: AbortSignal) => {
  const CancelToken = axios.CancelToken
  const source = CancelToken.source()
  /** Wrap your fetcher with Promise and resolve on timeout */
  const promise = new Promise((resolve) => setTimeout(resolve, 300)).then(() => fetcher(url!, params, source.token))

  signal?.addEventListener("abort", () => {
    source.cancel("Query was cancelled by React Query")
  })

  return promise
}

I like this answer from @thefaded ... I don't see why we can't just manage debouncing inline, and let react-query abort old requests.

e.g.

const sleep = (ms = 1000) => new Promise(resolve => setTimeout(resolve, ms))

const someQuery = useQuery({
    queryKey: ['someKey'],
    queryFn: async ({ signal }) => {
        await sleep(500)
        if (!signal?.aborted) {
            return (await axios.get(`/api/someRequest`, { signal })).data
        }
    }
});

@bobbjedi
Copy link

bobbjedi commented Aug 7, 2024

For my task (requesting a filtered list), this solution worked well. It also integrates smoothly with isLoading and other states. Although resolving all promises after the response isn't very elegant, I tested it and found no memory leaks

Some of the solutions suggested above that use Axios.abort do solve the race condition issue on the frontend, but they create unnecessary load on the server, as the server-side application continues processing the request.

This option avoids making an extra request to the backend:

type DebounceFunction<T> = () => Promise<T>;

interface DebounceOptions<T> {
  fn: () => Promise<T>;
  delaySeconds: number;
}

const createDebouncer = <T>({ fn, delaySeconds }: DebounceOptions<T>): DebounceFunction<T> => {
  let timerTimeOut: ReturnType<typeof setTimeout> | null = null;
  const resolvers: Set<(value: T | PromiseLike<T>) => void> = new Set();

  const refreshTimer = () => {
    if (timerTimeOut) clearTimeout(timerTimeOut);

    timerTimeOut = setTimeout(async () => {
      try {
        const result = await fn();
        resolvers.forEach(resolve => resolve(result));
      } catch (error) {
        console.error('Error in debouncer:', error);
        resolvers.forEach(resolve => resolve(Promise.reject(error)));
      } finally {
        resolvers.clear();
      }
    }, delaySeconds * 1000);
  };

  return () => {
    refreshTimer();
    return new Promise<T>((resolve) => {
      resolvers.add(resolve);
    });
  };
};

// Example use:
import { useQuery } from '@tanstack/vue-query';
import { Ref, unref } from 'vue';
import api from './api'; // Adjust the import to match your setup

export default ({ counter }: { counter: Ref<number> }) => {
  const debouncer = createDebouncer<string[]>({
    fn: () => api.putCounter(unref(counter)).then((data) => data),
    delaySeconds: 1,
  });

  return useQuery({
    queryKey: ['counter'],
    queryFn: debouncer,
  });
};

@MartinCura
Copy link

MartinCura commented Nov 10, 2024

Hi all, some integrated debouncing would be sweet (though i don't expect it any time soon 😉 ), this is how i solved it so that cached results are shown immediately (e.g. user types "A", types "B", then goes back to "A"), basically having my own set of used query keys and using that to know whether to use the debounced or the immediate input value:

// simplified from my code
const [inputValue, setInputValue] = useState<string>("")
const [searchQueriesSet, setSearchQueriesSet] = useState<Set<string>>(new Set())
const debouncedInputValue = useDebounce(inputValue, 2000)

const fetchSuggestions = useCallback(async (search: string) => {
  const response = await fetch(`/api/search?query=${search}`)
  const suggestions = searchResultsSchema.parse(await response.json())
  // Workaround for react-query not having debouncing, c.f. https://github.com/TanStack/query/issues/293
  setSearchQueriesSet((prev) => new Set([...prev, search]))
  return suggestions
}, [])

const searchQuery = searchQueriesSet.has(inputValue) ? inputValue : debouncedInputValue
const searchEnabled = searchQuery.length >= 8

const { data: suggestions, isFetching } = useQuery({
  queryKey: ["geo", "address-search", searchQuery],
  queryFn: () => fetchSuggestions(searchQuery),
  enabled: searchEnabled,
  staleTime: Infinity,
  gcTime: Infinity,
})

In case it's of help to anyone (or you have better suggestions, but i think this is fine for me).

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

No branches or pull requests