# useAsyncFetcher: How to implement an asynchronous fetch function in a Remix app

HHasan AyanJanuary 16, 20256 min read

NOTE

If you'd like to skip the reading part, we have created a package called `remix-use-async-fetcher`. It's on [GitHub](https://github.com/cerbos/remix-use-async-fetcher) and it contains what you need to call server loader and actions with a Promise return.

Although not yet published on npm, you can install it directly from git. If you wish to play with the demo app, visit [this StackBlitz page](https://stackblitz.com/github/cerbos/remix-use-async-fetcher).

The [Backend for Frontend](https://remix.run/docs/hi/main/guides/bff#backend-for-your-frontend) concept in Remix and its associated hooks such as `useLoaderData` or `useFetcher` are great tools for building dynamic pages. In most cases, these hooks provide a simple and reliable way of getting data from loaders. However, there are some cases where this concept imposes certain limitations, and while working on our Audit Logs feature, we hit one of them. In this article, I'll tell you about that limitation and show you how we implemented a solution for ourselves.

Since we wanted to implement bi-directional infinite scrolling for the Audit Logs page, we needed to be able to cache the loader data. Sure, you can use the existing Remix data hooks like `useFetcher` with some `useState` and `useEffect` magic, but implementing caching and invalidation can become complex depending on the requirements and you are likely to revisit your implementation when you have new requirements. This feels like reinventing the wheel when `@tanstack/query` already exists—a package for managing query caching that has been widely adopted by the React community for years. So, we wanted to use `@tanstack/query`. Unfortunately, `@tanstack/query` requires an async function to fetch the data and Remix loaders do not natively support that.

Using the native `fetch` function to retrieve loader data is an option, but if you are using [Single Fetch](https://remix.run/docs/en/main/guides/single-fetch#single-fetch), it requires handling the deserialization of `TurboStream` responses. This is not hard to figure out from looking at the Remix source but it's undocumented and there is no guarantee for compatibility with future Remix versions. We want to stick to "Remix way of doing things" and that's why we eliminated this option.

The good news is that Remix gives us `clientLoader` and `clientAction`. In this post, we will focus on `clientLoader`.

## How does `clientLoader` work?

When you call `useFetcher().load("my-route")`;

- Without a `clientLoader`, Remix calls the `loader` of `my-route` and updates the state of the `useFetcher` call with the data returned.
  
  ```
  ComponentuseFetcherloadercall load("my-route")call loader of "my-route"return dataupdate state with datare-render with the updated stateComponentuseFetcherloader
  ```
- If you have a `clientLoader` next to the `loader` of `my-route`, Remix calls `clientLoader` with request context, and an async `serverLoader` function to get the `loader` and data. What you return from your `clientLoader` will be used to update the state of the `useFetcher` call.
  
  ```
  ComponentuseFetcherclientLoaderloadercall load("my-route")call clientLoader with contextcall serverLoader for datareturn datareturn data to update stateupdate state with datare-render with the updated stateComponentuseFetcherclientLoaderloader
  ```

Remix presents this feature as a way to cache loader responses. In a `clientLoader`, you can decide if you'd like to serve the data from the cache or hit the backend. However, since we want `@tanstack/query` to handle caching, we will use `clientLoader`s only to create an async fetch function.

## How can `clientLoader` be used to create an async fetch function?

Here is the sequence diagram of what we are going to implement:

```
ComponentuseAsyncFetcheruseFetcherclientLoaderloaderalt[Data fetch successful][Data fetch failed]call async fetch function with "my-route"create Promise and return itcall original useFetcher().load with "my-route?__request-id=[unique-id]"call clientLoadercheck request URL for __request-idcall serverLoader for datareturn dataresolve Promise associated with __request-id with datareject Promise associated with __request-id with errorComponentuseAsyncFetcheruseFetcherclientLoaderloader
```

We will:
- Create a wrapper around `useFetcher().load` which will return a promise created by [`Promise.withResolvers()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/withResolvers) and then call the original `useFetcher().load` function after appending a unique ID as a URL search parameter keyed by `__request-id`.
- Store this request ID and the associated `Promise.withResolvers()` return somewhere globally accessible.
- Add `clientLoader` functions for the loaders we would like to be able to call asynchronously. They will:
  - Check the request URL in `clientLoader` and see if it has `__request-id`.
  - Call `serverLoader` to fetch the data.
  - If data fetching is successful, resolve the `Promise` associated with the `__request-id` with the data, otherwise, reject it with an error.
  - Return the resolved data to ensure Remix's data hooks function as expected.

And there you have it, an asynchronous function for fetching loader data in Remix.

## Coding time!

In `use-async-fetcher.ts` file;

First, define the `Map` that will hold what `Promise.withResolvers()` returns for each request.

```typescript
const asyncFetcherQueries: Map<string, PromiseWithResolvers<unknown>> = new Map<
  string,
  PromiseWithResolvers<unknown>
>();
```

Next, define `useAsyncFetcher` that returns an async fetch function, preferably within the same file.

```typescript
const requestIdKey = "__request-id";

export function useAsyncFetcher(): <T>(href: string) => Promise<T> {
  const originalFetcher = useFetcher();

const fetch = useCallback(
    async <T>(href: string): Promise<T> => {
      const requestId = crypto.randomUUID();

// append the request ID
      href = href.includes("?")
        ? `${href}&${requestIdKey}=${requestId}`
        : `${href}?${requestIdKey}=${requestId}`;

const promiseWithResolvers = Promise.withResolvers<T>();

// store promiseWithResolvers keyed by request ID
      asyncFetcherQueries.set(
        requestId,
        promiseWithResolvers as PromiseWithResolvers<unknown>,
      );

// initiate loader call
      originalFetcher.load(href);

// return the promise.
      return await promiseWithResolvers.promise;
    },
    [originalFetcher],
  );

return fetch;
}
```

WARNING

For each request made through `useAsyncFetcher`, we are adding a search parameter to the route. Using request URLs directly when building redirect URLs might be problematic. If you don't sanitize the redirect URL, this may inadvertently redirect the client to a URL containing `__request-id`

So far we've managed to initiate a request, store the associated `Promise` and return it. Now, we need to add `clientLoader` for each `loader` we want to be able to call asynchronously. Let's add a helper for that.

```typescript
export async function handleClientLoaderForAsyncFetcher({
  request,
  serverLoader,
}: ClientLoaderFunctionArgs): Promise<unknown> {
  const { searchParams } = new URL(request.url);
  const requestId = searchParams.get(requestIdKey);

try {
    // call the server loader
    const serverResponse = await serverLoader();

// if request ID is present, resolve the promise with the server data
    if (requestId) {
      // This is undocumented, but if you do a redirect in the loader,
      // `serverLoader()` will resolve to a Response object.
      // When we encounter a Response object we treat that as a fetch
      // error. Since we are planning to use @tanstack/react-query
      // we can rely on it retrying the request afterwards.
      if (serverResponse instanceof Response) {
        asyncFetcherQueries.get(requestId)?.reject(
            new Error("Encountered a Response object")
        );
      } else {
        asyncFetcherQueries.get(requestId)?.resolve(serverResponse);
        asyncFetcherQueries.delete(requestId);
      }
    }

// Return the data to ensure Remix's
    // data hooks function as expected.
    return serverResponse;
  } catch (e) {
    if (!requestId) {
      // rethrow when there is no Request ID
      // to ensure Remix's data hooks function
      // as expected.
      throw e;
    }

asyncFetcherQueries.get(requestId)?.reject(e);
    asyncFetcherQueries.delete(requestId);
    return null;
  }
}
```

DONE! Now we have an async fetch function to call the loaders. Here is a simple example of how `useAsyncFetcher` is used.

```typescript
export async function loader() {
  return json({ name: "Ryan", date: new Date() });
}

export const clientLoader = handleClientLoaderForAsyncFetcher;

export default MyPage(){
    const fetch = useAsyncFetcher();
    const [data, setData] = useState(undefined);
    const [error, setError] = useState(undefined);
    const [isLoading, setIsLoading] = useState(true);

useEffect(()=>{
        fetch(/account")
        .then(data=>{
            setData(data);
            setIsLoading(false);
        })
        .catch(error=>{
            setError(error);
            setIsLoading(false);
        })

},[]);

return <>...</>
}
```

That's it. `useAsyncFetcher` is all you need to be able to cache `loader` data using `@tanstack/react-query`. If you need to use `useMutation` from `@tanstack/react-query`, you can apply the same principle to `useFetcher().submit` & `clientAction.`
