query: Cannot debounce API calls

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

About this issue

  • Original URL
  • State: closed
  • Created 4 years ago
  • Reactions: 3
  • Comments: 35 (3 by maintainers)

Most upvoted comments

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?

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

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

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

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 🤙

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.

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?

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

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

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.

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 https://github.com/TanStack/query/issues/293#issuecomment-1238760706 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 😃

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 ❤️

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

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

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

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.

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

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!

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?

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

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
}

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

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.

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.

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

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

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