Jacob Paris
← Back to all content

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

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

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

ts
// Remix uses this internally but does not export it, so we can't steal theirs
type SubmitTarget =
| HTMLFormElement
| HTMLButtonElement
| HTMLInputElement
| FormData
| URLSearchParams
| {
[name: string]: string
}
| null
type DebounceSubmitFunction = (
target: SubmitTarget,
argOptions?: SubmitOptions & { debounceTimeout?: number },
) => void
const 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.

ts
const originalSubmit = fetcher.submit
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)
}
}

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.

ts
// This is a utility function that makes certain properties of a type required
type 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.

ts
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
}
| null
type DebounceSubmitFunction = (
target: SubmitTarget,
argOptions?: SubmitOptions & { debounceTimeout?: number },
) => void
type 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">
}
Professional headshot
Moulton
Moulton

Hey there! I'm a developer, designer, and digital nomad building cool things with Remix, and I'm also writing Moulton, the Remix Community Newsletter

About once per month, I send an email with:

  • New guides and tutorials
  • Upcoming talks, meetups, and events
  • Cool new libraries and packages
  • What's new in the latest versions of Remix

Stay up to date with everything in the Remix community by entering your email below.

Unsubscribe at any time.