-
-
Notifications
You must be signed in to change notification settings - Fork 3k
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
Comments
You need to use a debounce that supports promises. It needs to return a promise immediately, then debounce off of that promise. |
@tannerlinsley thanks for the reply. Can you give an example? |
Wow...there is no debounce package that does this? |
You may try to debounce |
@denisborovikov thanks...that works nicely! |
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? |
@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 |
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. const [searchTerm, setSearchTerm] = useState('')
const debouncedSearchTerm = useDebounce(searchTerm, 500)
const { data } = useSearch({ searchTerm: debouncedSearchTerm }) |
@tbntdima If you use |
@denisborovikov the problem is a bit different. When |
Another issue seems to arise when dealing with debouncers. Debounced |
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? |
I probably found something very similar to |
Does anyone have reference to this doubnce util that supports promises? |
@Noitidart you might want to look at |
Thanks @mrlubos! I actually did something very interesting. I used |
For anyone who is interested in debouncing a mutation, I hacked together this hook. It returns a mutation with the 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, |
FYI This link doesn't work anymore |
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 |
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 |
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.
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!
The |
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. |
@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:
now you can check for |
@vincerubinetti I get that this a feature requested by many, but there are always tradeoffs:
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:
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. |
Ok, I was definitely over-complicating things. I think your example more than suffices for what I'm trying to accomplish. |
@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. |
@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. |
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? |
Pretty much, yes. You would have to debounce the whole options:
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.
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
absolutely, PRs do the docs are always welcome ❤️ |
If you are here looking for some Vue answer, I used the watchDebounced from vueUse and it looks something like this:
Hope it helps! |
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 don't really need any of React hooks to handle debounce, here's an example with old version of 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'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 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 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 :) |
This implementation does not always debounce. Though the 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 |
I like this answer from @thefaded ... I don't see why we can't just manage debouncing inline, and let 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
}
}
}); |
For my task (requesting a filtered list), this solution worked well. It also integrates smoothly with Some of the solutions suggested above that use 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,
});
}; |
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). |
I am trying to use this for a search component, like:
I would like the call to
getSearchAsync
to be debounced while the user is typing the query. Tried adding lodash.debounce like: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).The text was updated successfully, but these errors were encountered: