Data Loading
Most of the time in a real application, every route carries with it some data requirements.
Rather than managing component-level loading states or suspense fallbacks and watching the
page layout churn as everything resolves, JARL lets you attach a loader to a route atom and
resolve everything it needs before the route ever renders - jotai's own async-atom machinery,
behind a single Suspense boundary in React, handles the wait.
asyncRouteAtom (from jarl-atoms) takes a route atom, a name for what the loader produces,
and the loader itself. Its .data is a plain jotai async atom that resolves once that route
matches:
routes.ts:
import { staticRouteAtom, paramRouteAtom, asyncRouteAtom } from "jarl-atoms";
export const productsRoute = staticRouteAtom("products");
export const productRoute = paramRouteAtom("productId", { parent: productsRoute });
export const productData = asyncRouteAtom(productRoute, "product", async ({ productId }) => {
const result = await fetch(`/api/products/${productId}`);
return result.json();
}).data;.data is Atom<Promise<Data | Redirect | undefined>>, so any of jotai's usual ways of
consuming an async atom work - the most idiomatic in React is useAtomValue under a Suspense
boundary:
import { Suspense } from "react";
import { useAtomValue } from "jarl-react";
import { productData } from "./routes";
const ProductPage = () => {
const product = useAtomValue(productData);
return <ProductView product={product} />;
};
export default () => (
<Suspense fallback="Loading...">
<ProductPage />
</Suspense>
);By loading data as part of the route atom itself, the data is guaranteed to exist (or the
loader's Promise is still pending, transparently handled by Suspense) by the time
ProductPage renders - no separate loading flag to plumb through. If you'd rather not suspend,
jotai/utils' loadable() wraps any async atom into a synchronous { state: "hasData" | "loading" | "hasError", ... } value instead.
Read this way, .data has no bearing on whether the route matches and needs nothing else wired
up. The name you passed ("product" above) is only used by the next section.
Routes that only exist if the data does
Sometimes the lookup is the route. Whether /blog/some-slug is a page at all is a question only
the database can answer, and answering it twice - once to decide, once to render - is a wasted
call. Drop the .data and keep the atom itself: the load then decides the match, and whatever it
found is bound to the route's own values under the name you gave it.
import { staticRouteAtom, paramRouteAtom, asyncRouteAtom } from "jarl-atoms";
import { db } from "./db";
export const blogRoute = staticRouteAtom("blog");
export const slugRoute = paramRouteAtom("slug", { parent: blogRoute });
export const postRoute = asyncRouteAtom(slugRoute, "post", ({ slug }) => db.findPost(slug));
/** Every async route in the app, in one list to preload, hydrate and follow. */
export const asyncRoutes = [postRoute];undefined back from the loader means the route doesn't match, so a Switch fallback (or
notAtom) renders your not-found case. A hit matches, with the loaded object typed onto values:
<Switch fallback={<NotFound />}>
<Route on={postRoute} exact>
{({ post }) => <PostView post={post} />}
</Route>
</Switch>post here is a Post, not an unknown you have to narrow, and PostView fetches nothing of
its own.
Server rendering
Route matching is synchronous everywhere in JARL, so the load has to have settled before a
render can read it. On the server that is a single await, and it is what lets the response
carry a real 404 status rather than a 200 whose body happens to say "not found":
const store = createStore();
store.set(locationAtom, { pathname, searchParams });
const routeData = await preloadAsyncRoutes(store, asyncRoutes);
const html = renderToString(
<Provider store={store}>
<App />
</Provider>,
);
const status = store.get(notFoundAtom) ? 404 : 200;notFoundAtom there is notAtom(unionRouteAtom(everyRouteYouRender)) - listing postRoute rather
than slugRoute, so an unknown slug counts as a miss.
preloadAsyncRoutes returns one snapshot per route, in the order given. Serialise them into the
page and the client picks up where the server left off, without repeating the load:
hydrateAsyncRoutes(store, asyncRoutes, window.__ROUTE_DATA__ ?? []);
followAsyncRoutes(store, asyncRoutes);
hydrateRoot(root, <Provider store={store}>{<App />}</Provider>);followAsyncRoutes keeps the routes settled from then on, re-running each load as the location
changes. While one is in flight, postRoute.pending is true - render a spinner on it, or the
not-found case will flash before the answer arrives.
This lifecycle is what gating costs. A route read only through .data never needs any of it.
Redirecting
Sometimes a route shouldn't render at all, and should instead send the visitor somewhere else -
an auth gate, a canonical-URL redirect, or (as below) a loader that didn't find what it was
looking for. redirect(to) marks that outcome:
import { staticRouteAtom, paramRouteAtom, asyncRouteAtom, redirect } from "jarl-atoms";
export const productBySlugRoute = paramRouteAtom("productSlug", { parent: productsRoute });
export const productBySlugData = asyncRouteAtom(productBySlugRoute, "product", async ({ productSlug }) => {
const response = await fetch(`/api/productsBySlug?slug=${productSlug}`);
if (!response.ok) {
return redirect("/products/not-found");
}
return response.json();
}).data;A Redirect returned from a loader doesn't navigate anywhere by itself - reading the atom
just tells you a redirect would happen, which keeps it composable and testable like any other
value. To actually perform the navigation, wire followAsyncRedirects up once near the root
of your app (typically alongside where you create your jotai store):
import { followAsyncRedirects } from "jarl-atoms";
import { productBySlugData } from "./routes";
const unsubscribe = followAsyncRedirects(store, [productBySlugData]);It subscribes to each .data atom given and, the moment one produces a Redirect, replaces
the current location with its target (history.replaceState, so the abandoned URL doesn't
linger in the back-button history).
If a route should redirect unconditionally - with no data fetch involved at all -
redirectRouteAtom/followRedirects do the same job without a loader:
import { redirectRouteAtom, followRedirects } from "jarl-atoms";
import { staticRouteAtom } from "jarl-atoms";
export const oldAboutRoute = staticRouteAtom("about-us");
export const oldAboutRedirect = redirectRouteAtom("/about", { parent: oldAboutRoute });
followRedirects(store, [oldAboutRedirect]);