# Debounce Loaders and Actions in React Router

Used: react-router@7.0.0

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:

```ts
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:

```ts
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`:

```ts
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:

```ts
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`:

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