How toDebounce Loaders and Actions in React Router
There are many ways to implement debounce in React. A common approach is to wrap the function you want to debounce and call that instead:
let fetcher = useFetcher<typeof loader>();
let debouncedFetcher = useDebounce(fetcher.submit, 500);
function handleSubmit() {
debouncedFetcher(data, options);
}
This works, but it requires remembering to debounce every time you use that loader
. A better approach is to let the route itself control the debounce behavior.
Debouncing a Loader
Suppose you have a loader that performs a search using a query parameter—for example, to fetch suggestions as the user types:
export async function loader({ request }: Route.LoaderArgs) {
let url = new URL(request.url);
let query = url.searchParams.get("query");
let [posts, { page, total }] = await Post.findBy({ name: query });
return { posts, meta: { page, total } };
}
You don’t want this loader to run on every keystroke. Instead, you can debounce it using a clientLoader
:
import { setTimeout } from "node:timers/promises";
export async function clientLoader({
request,
serverLoader,
}: Route.ClientLoaderArgs) {
return await setTimeout(500, serverLoader, { signal: request.signal });
}
This debounces the loader
by 500ms. While the clientLoader
is called on every keystroke, the serverLoader
only runs after 500ms of inactivity.
If the same fetcher calls clientLoader
again before the delay completes, the previous request is aborted using the AbortSignal
from request.signal
. This means that if the user types quickly, only the last input after a pause will trigger the actual loader.
Debouncing an Action
This technique also works for actions. Consider an action that tracks scroll position to update how much of a post the user has read:
export async function action({
request,
params: { postId },
}: Route.ActionArgs) {
let url = new URL(request.url);
let scrollY = url.searchParams.get("scrollY");
let post = await Post.findById(postId);
await post.update({ read: (100 * scrollY) / post.height });
return null;
}
This action could be triggered on every scroll event, which is inefficient. Instead, debounce it using a clientAction
:
import { setTimeout } from "node:timers/promises";
export async function clientAction({
request,
serverAction,
}: Route.ClientActionArgs) {
return await setTimeout(50, serverAction, { signal: request.signal });
}
Now the action only runs after 50ms of no scrolling activity.
Why This Matters
By moving debounce logic into the route with clientLoader
and clientAction
, you:
- Centralize control of timing behavior
- Make debounce time easy to change or remove
- Avoid duplicating debounce logic in every component
This leads to cleaner, more maintainable code and gives each route the ability to define when its logic runs—not just what it does.
With this approach, your React components stay focused on rendering, while the route controls debounce behavior. This separation of concerns is key to building scalable applications.
It even allows you to debounce forms using Form
or fetcher.Form
without needing an onSubmit
handler. Just use a clientLoader
or clientAction
in the route, and the debounce will be handled automatically.