Skip to content
Twilight React Playground
ثيم رائدaren

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 product from @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 loaderComponent with useQuery
RunsBefore the page is drawn: on the server for a first visit, in the browser when the shopper follows a linkAfter the component appears, in the browser only
In the server's HTMLYes: shoppers and search engines see it at onceNo: the HTML has the loading state
Use it forWhat the page is about: the product, the category, the articleWhat 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 one QueryClient (queries: staleTime 60 s, gcTime 5 min, retry: 1, no refetch on window focus) and passes it to loaders as context.queryClient; engine loaders reach the same client through getTwilightContext().queryClient (src/tanstack/router.tsx).
  • The router's own defaultStaleTime is Infinity and 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 CartPage loads the cart with useQuery (src/routes/cart/loader.ts).
  • useQuery does not fetch during the server render; useSuspenseQuery suspends until data arrives, so pair it with a loader (or a Suspense boundary).
  • Reference: product.list and queries, product.find, the API client, createRouter, and Fork a route to reuse an engine loader.
Check yourself

The cart's contents depend on an id stored in the shopper's browser. Where do you load them?