Loading data with loaders and queries
Beginner12 min
Fetch store data before a page renders with a loader, or from a component with useQuery, sharing one cache.
A page shows data from the Salla API: products, categories, menus. The engine gives you that data in two layers.
- API modules such as
productfrom@salla.sa/twilight-theme-engine/api/product: plain async functions (product.list(),product.find()) that already know the address, the store and the language. - Query options on each module (
product.queries.list()): the same request packaged for TanStack Query, a library that keeps one shared cache of everything the page has fetched.
And there are two places to ask: a route's loader, before the page is drawn, or a component, after it is drawn.
The functions
import { product } from '@salla.sa/twilight-theme-engine/api/product';
// Plain async functions: usable in a loader, an event handler, anywhere.
const { items, next } = await product.list({ source: 'latest', perPage: 8 });
const armchair = await product.find('1303461379');
One cache, shared by every component
Both components below ask for the newest products with the same options. They do not make two requests: the first one fetches, the second one reads the cache, and both show the same fetch time. Change perPage to ask with a different key, then use Refetch and watch both update together.
Idle
Component A: namesLoading…data from …
Component B: price rangeLoading…data from …
app/components/LatestNames.tsx
import { useQuery } from '@tanstack/react-query';
import { product } from '@salla.sa/twilight-theme-engine/api/product';
export function LatestNames() {
// queries.list() returns the key and the function to call: useQuery does the rest.
const { data, isPending } = useQuery(product.queries.list({ source: 'latest', perPage: 4 }));
if (isPending) return <p>Loading…</p>;
return (
<ul>
{data?.items.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
}
Loader or component?
| Route loader | Component with useQuery | |
|---|---|---|
| Runs | Before the page is drawn: on the server for a first visit, in the browser when the shopper follows a link | After the component appears, in the browser only |
| In the server's HTML | Yes: shoppers and search engines see it at once | No: the HTML has the loading state |
| Use it for | What the page is about: the product, the category, the article | What depends on the browser (the cart, whose id lives there) or can arrive a moment later (a related-products slider) |
app/routes/new-arrivals.tsx
// app/routes/new-arrivals.tsx, listed in app/routes.ts as route('/new-arrivals', 'new-arrivals.tsx')
import { createFileRoute } from '@tanstack/react-router';
import { useSuspenseQuery } from '@tanstack/react-query';
import { product } from '@salla.sa/twilight-theme-engine/api/product';
import { ProductCard } from '@salla.sa/twilight-theme-engine/components/product';
const latest = () => product.queries.list({ source: 'latest', perPage: 12 });
export const Route = createFileRoute('/{-$locale}/new-arrivals')({
// Fetch before drawing, into the shared cache. On a first visit this runs on the
// server, so the products are in the HTML the shopper and search engines receive.
loader: ({ context }) => context.queryClient.ensureQueryData(latest()),
component: NewArrivals,
});
function NewArrivals() {
// Same key: the loader already filled it, so nothing is fetched and nothing waits.
const { data } = useSuspenseQuery(latest());
return (
<div className="s-products-list-wrapper s-products-list-vertical-cards">
{data.items.map((item) => (
<ProductCard key={item.id} product={item} />
))}
</div>
);
}
In engine terms
createRouter()makes oneQueryClient(queries:staleTime60 s,gcTime5 min,retry: 1, no refetch on window focus) and passes it to loaders ascontext.queryClient; engine loaders reach the same client throughgetTwilightContext().queryClient(src/tanstack/router.tsx).- The router's own
defaultStaleTimeisInfinityand links preload on intent, so hovering a link can run its loader early. - Query keys include the active branch,
{ scope }, because stock and availability depend on it. That is why the key above is shown only after hydration. - The engine's cart page loader returns only the page title: the cart id lives in the browser, so
CartPageloads the cart withuseQuery(src/routes/cart/loader.ts). useQuerydoes not fetch during the server render;useSuspenseQuerysuspends until data arrives, so pair it with a loader (or aSuspenseboundary).- Reference: product.list and queries, product.find, the API client, createRouter, and Fork a route to reuse an engine loader.