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
.
clientLoader
work?When you call useFetcher().load("my-route")
;
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
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 clientLoader
s only to create an async fetch function.
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:
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
.Promise.withResolvers()
return somewhere globally accessible.clientLoader
functions for the loaders we would like to be able to call asynchronously. They will:
clientLoader
and see if it has __request-id
.serverLoader
to fetch the data.Promise
associated with the __request-id
with the data, otherwise, reject it with an error.And there you have it, an asynchronous function for fetching loader data in Remix.
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
Join thousands of developers | Features and updates | 1x per month | No spam, just goodies.