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

Published by Hasan Ayan on January 16, 2025
useAsyncFetcher: How to implement an asynchronous fetch function in a Remix app

NOTE

If you'd like to skip the reading part, we have created a package called remix-use-async-fetcher. It's on GitHub 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.

The Backend for 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, 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.
sequenceDiagram
    participant Component
    participant useFetcher
    participant loader

    Component->>useFetcher: call load("my-route")
    useFetcher->>loader: call loader of "my-route"
    loader-->>useFetcher: return data
    useFetcher-->>useFetcher: update state with data
    useFetcher-->>Component: re-render with the updated state
  • 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.
sequenceDiagram
    participant Component
    participant useFetcher
    participant clientLoader
    participant loader

    Component->>useFetcher: call load("my-route")
    useFetcher->>clientLoader: call clientLoader with context
    clientLoader->>loader: call serverLoader for data
    loader-->>clientLoader: return data
    clientLoader-->>useFetcher: return data to update state
    useFetcher-->>useFetcher: update state with data
    useFetcher-->>Component: re-render with the updated state

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 clientLoaders 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:

sequenceDiagram
    participant Component
    participant useAsyncFetcher
    participant useFetcher
    participant clientLoader
    participant loader

    Component->>useAsyncFetcher: call async fetch function with "my-route"
    useAsyncFetcher->>Component: create Promise and return it
    useAsyncFetcher->>useFetcher: call original useFetcher().load with "my-route?__request-id=[unique-id]"

    useFetcher->>clientLoader: call clientLoader
    clientLoader->>clientLoader: check request URL for __request-id
    clientLoader->>loader: call serverLoader for data
    loader-->>clientLoader: return data

    alt Data fetch successful
        clientLoader->>Component: resolve Promise associated with __request-id with data
    else Data fetch failed
        clientLoader->>Component: reject Promise associated with __request-id with error
    end

We will:

  • Create a wrapper around useFetcher().load which will return a promise created by 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.

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.

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.

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.

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.

Book a free Policy Workshop to discuss your requirements and get your first policy written by the Cerbos team