Debounce your useFetcher submissions with this custom Remix hook
This useDebounceFetcher hook has been moved to Remix Utils. I recommend you use that instead of this snippet.
If you try to submit a form with Remix's useFetcher twice in a row, only one of the submissions will go through.
This is part of Remix's built-in protection against double form submissions and stale data.
When sending multiple requests quickly, like while typing or dragging a range input, you don't want to send hundreds of requests. If one of the middle requests happens to be slower than the later ones, it could even start undoing changes you've tried to make.
So Remix handles both automatic request cancellation and resolving out-of-order responses for you, right in useFetcher.
That takes care of the client, but many of those requests are still going to hit your server.
If you know most of them are going to be cancelled anyway, you can save your server some work by debouncing the request on the client.
Using a custom fetcher hook, you can add a debounced submit function that will only send the last request in a series of rapid-fire requests.
We can implement a debounce by replacing an actual function call with a timeout that triggers it later. If the function is called again before the timeout is up, we cancel the timeout and start a new one.
This is what the hook looks like to use
const fetcher = useDebounceFetcher()fetcher.submit(form, { // the rest of the fetcher options still work // this is the only new option here debounceTimeout: 1000,})
Implementation
We can store the timeout in a ref so it persists between renders.
Make sure you clean it up too when the component unmounts, or you'll get a memory leak.
const timeoutRef = useRef<NodeJS.Timeout>()useEffect(() => { // no initialize step required since timeoutRef defaults undefined return () => { if (timeoutRef.current) { clearTimeout(timeoutRef.current) } }}, [timeoutRef])
Next we'll need a fetcher. We'll end up returning this for consumers of the custom hook to use, but it'll be a modified version that supports debouncing.
To keep Typescript happy, we need to tell it that the fetcher we're returning may have a debounceSubmit function. We'll do that by casting it to a type that has that function.
The debounceSubmit function will be identical in type to the regular submit function, except with an extra debounceTimeout option.
// Remix uses this internally but does not export it, so we can't steal theirstype SubmitTarget = | HTMLFormElement | HTMLButtonElement | HTMLInputElement | FormData | URLSearchParams | { [name: string]: string } | nulltype DebounceSubmitFunction = ( target: SubmitTarget, argOptions?: SubmitOptions & { debounceTimeout?: number },) => voidconst fetcher = useFetcher() as ReturnType< typeof useFetcher> & { debounceSubmit?: DebounceSubmitFunction}
Now we can implement the debounceSubmit function.
First we'll check if there's already a timeout running. If there is, we'll cancel it.
Then, start the new timeout that will trigger the actual fetcher.submit call later.
const originalSubmit = fetcher.submitfetcher.debounceSubmit = (target, argOptions) => { if (timeoutRef.current) { clearTimeout(timeoutRef.current) } const { debounceTimeout = 0, ...options } = argOptions || {} if (debounceTimeout && debounceTimeout > 0) { timeoutRef.current = setTimeout(() => { fetcher.submit(target, options) }, debounceTimeout) } else { fetcher.submit(target, options) }}
The last step is to return the fetcher. Even though we've just explicitly assigned a debounceSubmit function to it, Typescript won't narrow the type
We originally set it to DebounceSubmitFunction | undefined
and now we've just proven in code that it's no longer undefined.
The easiest way to fix this is to assert the property is required when we return the fetcher.
// This is a utility function that makes certain properties of a type requiredtype Required<Type, Key extends keyof Type> = Type & { [Property in Key]-?: Type[Property]}return fetcher as Required<typeof fetcher, "debounceSubmit">
One last thing: useFetcher
is actually a generic function that can take a type argument. It's a good idea to make our custom hook support this as well, and we can just pass it down everywhere useFetcher is called. Look for <T>
in the final example to see where this has happened.
The final hook
I recommend using the Remix Utils implementation of this hook so you get subscribed to updates and bug fixes.
The main difference between that one and this one is that it modifies the .submit()
function instead of adding a .debounceSubmit()
function.
Here is a complete useDebounceFetcher hook you can copy/paste into your app.
import type { SubmitOptions } from "@remix-run/react"import { useFetcher } from "@remix-run/react"import { useEffect, useRef } from "react"type SubmitTarget = | HTMLFormElement | HTMLButtonElement | HTMLInputElement | FormData | URLSearchParams | { [name: string]: string } | nulltype DebounceSubmitFunction = ( target: SubmitTarget, argOptions?: SubmitOptions & { debounceTimeout?: number },) => voidtype Required<Type, Key extends keyof Type> = Type & { [Property in Key]-?: Type[Property];};export function useDebounceFetcher<T>() { const timeoutRef = useRef<NodeJS.Timeout>() useEffect(() => { // no initialize step required since timeoutRef defaults undefined return () => { if (timeoutRef.current) { clearTimeout(timeoutRef.current) } } }, [timeoutRef]) const fetcher = useFetcher<T>() as ReturnType<typeof useFetcher<T>> & { debounceSubmit?: DebounceSubmitFunction } fetcher.debounceSubmit = (target, argOptions) => { if (timeoutRef.current) { clearTimeout(timeoutRef.current) } const { debounceTimeout = 0, ...options } = argOptions || {} if (debounceTimeout && debounceTimeout > 0) { timeoutRef.current = setTimeout(() => { fetcher.submit(target, options) }, debounceTimeout) } else { fetcher.submit(target, options) } } return fetcher as Required<typeof fetcher, "debounceSubmit">}