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

Reading and writing data

Beginner8 min

Reads go through loaders, the api modules and the query cache; writes go through window.Salla and come back as SDK events.

Data moves in two directions, on two different roads.

  • Reading (products, categories, the menu) happens before the page is drawn, in a loader, and usually on the server. The result travels to the browser inside the HTML.
  • Writing (add to cart, wishlist, coupons) happens in the browser, after a click, through the Salla SDK. The result comes back as an event.

The top rows of the diagram are the reading road; the bottom row is the writing road.

Route loaderserver firstapi moduleproduct.list()api clientheaders addedSalla APIapi.salla.devQuery cacheQueryClientHTML + datadehydratedComponentuseQuery · loader dataClick handlerbrowser onlywindow.Sallacart.addItem()SDK eventcart::updatedListenershooks, web components

1. A loader asks for the data

Before a page is drawn, its loader (a function that runs before the page) asks for what the page shows. For the first page it runs on the server; after that, in the browser. It uses the engine's ready-made functions, such as product.list().

In engine terms

The generated route calls the module loader, e.g. Product.loaderproductLoaderproduct.findOrThrow(id) (src/api/product.ts). Other loaders go through the QueryClient: homeLoader calls queryClient.ensureQueryData(home.queries.components()), the listing loaders queryClient.fetchQuery(product.queries.list(…)), and the root beforeLoad ensureQueryData(store.queries.settings()).

Reading: loader first, component second

app/routes/lookbook.tsx
import { createFileRoute } from '@tanstack/react-router';
import { useQuery } from '@tanstack/react-query';
import { getTwilightContext } from '@salla.sa/twilight-theme-engine/tanstack';
import { product } from '@salla.sa/twilight-theme-engine/api/product';

const latest = () => product.queries.list({ source: 'latest', perPage: 8 });

// Declared in app/routes.ts as route('/lookbook', 'lookbook.tsx').
export const Route = createFileRoute('/{-$locale}/lookbook')({
  // Read in the loader: the products are in the server HTML.
  loader: async () => {
    await getTwilightContext().queryClient.ensureQueryData(latest());
    return { page: { slug: 'lookbook', title: 'Lookbook' } };
  },
  component: Lookbook,
});

function Lookbook() {
  // Same query key: answered from the cache the loader filled, no second request.
  const { data } = useQuery(latest());
  return <ul>{data?.items.map((item) => <li key={item.id}>{item.name}</li>)}</ul>;
}

What this page has cached

This is the query cache of the tab you are reading. Keys starting with store and translations were filled on the server by the root route and arrived with the HTML; the rest came from demos and pages you opened in this tab.

Reading the cache…

Writing: through the SDK, back as an event

app/components/QuickAdd.tsx
import { useEffect } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { getSallaSDK } from '@salla.sa/twilight-theme-engine/utils';

// Write: in a click handler, through the SDK.
export function QuickAdd({ productId }: { productId: number }) {
  return (
    <button type="button" onClick={() => void getSallaSDK()?.cart.addItem(productId, 1)}>
      Add to cart
    </button>
  );
}

// React to the write: nothing refreshes your own queries for you.
export function useRefreshCartQueries() {
  const queryClient = useQueryClient();

  useEffect(() => {
    const refresh = () => void queryClient.invalidateQueries({ queryKey: ['cart'] });
    let sdk = getSallaSDK();
    let active = true;
    void sdk?.onReady().then(() => {
      sdk = getSallaSDK();
      if (active) sdk?.event.on('cart::updated', refresh);
    });
    return () => {
      active = false;
      sdk?.event?.off('cart::updated', refresh);
    };
  }, [queryClient]);
}

Try it: press Add to cart on a card below (it adds to your own guest cart on the demo store), then watch the event log under it.

The store's newest products, each drawn by the engine's ProductCard. Add to cart is the real Salla button.Try this: switch the layout to horizontal and watch the heart button move from the image to the footer; then tick withoutAddButton.
Real requests to the demo store
Storefront canvas · en · LTR

Loading products…

Controls
withShadow
withQuantityShows "Remained N" or "Out of Stock" when no promotion title takes the badge.
withoutAddButton
What a theme writes
import { useQuery } from '@tanstack/react-query';
import { product } from '@salla.sa/twilight-theme-engine/api/product';
import { ProductCard } from '@salla.sa/twilight-theme-engine/components/product';

export function LatestProducts() {
  const { data } = useQuery(product.queries.list({ source: 'latest', perPage: 8 }));
  return (
    <div className="s-products-list-wrapper s-products-list-vertical-cards">
      {data?.items.map((item, index) => (
        <ProductCard
          key={item.id}
          product={item}
          index={index}
          imagePriority={index < 2}
        />
      ))}
    </div>
  );
}

Waiting for the SDK…

Why it matters

  • Data fetched in `useEffect` is missing from the server HTML: the shopper sees a spinner first, and search engines may see nothing. Read it in the loader and share it through a query key.
  • `window.Salla` in a loader throws on the server, where the first page is loaded. Keep SDK calls in event handlers and effects.
  • The screen does not change after a write you made yourself: the engine does not refresh your queries. Listen for the SDK event and invalidate them, or use a hook that already listens, such as useWishlist.
  • A request answered for the wrong store or language means the header came from somewhere else: always call Salla through the engine's api modules, not a bare fetch.
Check yourself

Where should Salla.cart.addItem() be called?