Product Catalog

React

@usethrottle/catalog/react wraps the catalog client in a provider and hooks, with a small cache, a session-backed cart and review submission. It works with React 18.2 and 19 (an optional peer dependency), is marked "use client", and needs no other library.

CatalogProvider

PropMeaning
clientA client from createCatalogClient. Required.
customerKeyThe signed-in customer's id, or null. It partitions the cache and the cart by customer. Required whenever customerToken can return a token.
cartStorageWhere useCart keeps the cart session id: { get, set, remove }, each sync or async. The default keeps it in memory only.
cartUrls{ returnUrl?, cancelUrl?, storefrontUrl? }, sent on every cart useCart creates, so each must be an absolute https URL on an allowed return website (otherwise leave them out and pass URLs to checkout(input)), and used by its checkout(). See return URLs.
throttleBaseUrlThrottle's API, for the cart session. Default https://api.usethrottle.dev.
throttleCheckoutUrlThrottle's hosted pages, for a cart link the hook builds itself. Default https://checkout.usethrottle.dev.
One client, customer prices and a persistent cart
'use client';
import { useMemo, type ReactNode } from 'react';
import { createThrottleAuth } from '@usethrottle/auth';
import { ThrottleAuthProvider, useAuth } from '@usethrottle/auth/react';
import { createCatalogClient } from '@usethrottle/catalog';
import { CatalogProvider, type CartStorage } from '@usethrottle/catalog/react';

// One auth client and one catalog client for the life of the app. Never make
// a new catalog client when the shopper signs in: the token is read from
// this function on every request.
const auth = createThrottleAuth({ publishableKey: 'pk_live_your_throttle_key' });
const catalog = createCatalogClient({
  publishableKey: 'pc_pk_your_browser_key',
  customerToken: () => auth.getAccessToken(),
});

// Keeps the cart across reloads, in one slot per customer. On a sign-in,
// useCart moves a guest cart into the customer's slot, or loads the
// customer's saved cart when the guest had none. Another tab's sign-out is not
// visible here, so a shared slot could hand one shopper's cart to the next.
function localCartStorage(customerId: string | null): CartStorage {
  const key = `catalog-cart:${customerId ?? 'guest'}`;
  return {
    get: () => localStorage.getItem(key),
    set: (id) => localStorage.setItem(key, id),
    remove: () => localStorage.removeItem(key),
  };
}

function Catalog({ children }: { children: ReactNode }) {
  // Changes in the same render as the session, so the cache and the cart
  // never cross from one shopper to another.
  const { customer } = useAuth();
  const customerId = customer?.id ?? null;
  const cartStorage = useMemo(() => localCartStorage(customerId), [customerId]);

  return (
    <CatalogProvider
      client={catalog}
      customerKey={customerId}
      cartStorage={cartStorage}
      cartUrls={{
        returnUrl: 'https://shop.example.com/order-complete',
        cancelUrl: 'https://shop.example.com/cart',
        storefrontUrl: 'https://shop.example.com/shop',
      }}
    >
      {children}
    </CatalogProvider>
  );
}

export function Providers({ children }: { children: ReactNode }) {
  return (
    <ThrottleAuthProvider publishableKey="pk_live_your_throttle_key" auth={auth}>
      <Catalog>{children}</Catalog>
    </ThrottleAuthProvider>
  );
}
  • Keep one client per currency and language, memoised on both; on a currency switch run useCart().switchCurrency() first and swap the client once it resolves. Pass customerToken as a function. With @usethrottle/auth 0.10.1 or later that is () => auth.getAccessToken(), read on every request. A new client per customer would start a new cart.
  • Change customerKey in the same update as the token. Reading the customer from useAuth(), as above, does that. A change starts an empty cache, so every mounted hook fetches again and one shopper never sees prices fetched for another. It is required whenever customerToken can return a token: the hook drops the cart on a sign-out or a customer change only by watching it, so without it one customer's cart stays for the next shopper.
  • In the browser, set keyInQuery: true on the client so reads need no preflight; see Keys and channels.
  • useCatalog() answers the provider's client, for a call no hook covers.

The read hooks

HookCallsdata
useProducts(query, options?)products.listProducts, page, filters, applied selection.
useProduct(handle, { include? })products.get{ product }, or { redirectTo } for a renamed handle.
useRelated(handle, { limit? })products.related{ products }
useFilterValues(key, query?, options?)filters.valuesEvery value of one filter for a selection.
useCategories({ depth? })categories.treeThe category tree.
useCollection(handle)collections.getThe collection page, or { redirectTo }.
useSuggest(q, { debounceMs?, limit? })suggestSuggestions. No request under 2 characters; one per pause in typing (200 ms by default).
useReviews(handle, { sort?, rating?, limit?, cursor? })reviews.listReviews and their summary, or { redirectTo }.
useConfig(options?)configCurrencies, locales, sorts, the review settings and the generation.

Every read hook (and the three in the extras entry) answers { data, dataFor, error, isLoading, isPreviousData, isStale, isValidating, refetch }:

  • data: the answer, typed as the client's call answers it, or undefined.
  • error: the CatalogError of the last attempt, or undefined. A failed refresh keeps the last data.
  • isLoading: no data and no error yet.
  • isValidating: a request for this key is in flight, first load or refresh.
  • refetch(): fetch again now. It never rejects.

They take enabled (false makes no request), initialData and staleTimeMs. A null handle or key makes no request either, which is how one hook waits for another.

A grid and a product page
'use client';
import { useProduct, useProducts, useRelated } from '@usethrottle/catalog/react';

// Inside a <CatalogProvider>.
export function CategoryGrid({ category }: { category: string }) {
  const { data, error, isLoading, isValidating, refetch } = useProducts({
    category,
    limit: 24,
  });

  if (isLoading) return <p>Loading…</p>;
  if (error && !data) {
    return <button onClick={() => void refetch()}>Try again</button>;
  }
  return (
    // Cached data shows at once; isValidating is true while it is refreshed.
    <ul aria-busy={isValidating}>
      {data?.products.map((product) => (
        <li key={product.handle}>{product.title ?? product.handle}</li>
      ))}
    </ul>
  );
}

export function ProductDetails({ handle }: { handle: string }) {
  const product = useProduct(handle);
  // null makes no request: related products wait for a product that exists.
  const found = product.data !== undefined && !('redirectTo' in product.data);
  const related = useRelated(found ? handle : null, { limit: 8 });

  if (product.error?.code === 'NOT_FOUND') return <p>Product not found.</p>;
  if (product.data === undefined) return null;
  if ('redirectTo' in product.data) {
    // A renamed product: its handle changed.
    return <a href={`/products/${product.data.redirectTo}`}>See the product</a>;
  }

  const { title, variants } = product.data.product;
  return (
    <article>
      <h1>{title}</h1>
      <p>{variants.length} options</p>
      <ul>
        {related.data?.products.map((item) => (
          <li key={item.handle}>{item.title}</li>
        ))}
      </ul>
    </article>
  );
}

Each hook also answers dataFor (the query the shown data answers), isPreviousData and isStale, and takes keepPreviousData; see Keeping previous data and Refresh errors.

useTags, useCollections and useReviewEligibility

These three hooks (1.2.0 and later) live in @usethrottle/catalog/react/extras, which keeps the /react entry small. They use the provider and the cache of /react: import CatalogProvider from /react and these from /react/extras.

HookCallsdata
useTags({ q?, limit? })tags.list{ tags }
useCollections({ limit?, cursor? })collections.list{ collections }
useReviewEligibility(handle)reviews.eligibilityWhether the signed-in shopper may review the product (canReview). No request while signed out or while handle is null; asked again on every mount and kept per customer.
Tags, collections and a review button
'use client';
import {
  useCollections,
  useReviewEligibility,
  useTags,
} from '@usethrottle/catalog/react/extras';

// Inside a <CatalogProvider> from '@usethrottle/catalog/react'.
export function Filters() {
  const tags = useTags({ q: 'fr', limit: 5 });
  const collections = useCollections({ limit: 6 });

  return (
    <nav>
      {tags.data?.tags.map((tag) => (
        <span key={tag.tag}>{tag.tag}</span>
      ))}
      {collections.data?.collections.map((collection) => (
        <a key={collection.handle} href={`/collections/${collection.handle}`}>
          {collection.title}
        </a>
      ))}
    </nav>
  );
}

export function ReviewButton({ handle }: { handle: string }) {
  // No request while signed out or while handle is null. Asked again on every
  // mount, and kept per customer.
  const { data } = useReviewEligibility(handle);
  return data?.canReview ? <a href="#review">Write a review</a> : null;
}

useCustomerKey(), from /react, answers the provider's customerKey. The ReviewEligibility type, the answer of reviews.eligibility, is exported from either entry.

Keeping previous data

keepPreviousData: true keeps a hook's last settled answer on screen while the next key loads, so a filter change or a typed query does not flash empty.

A grid that keeps its last answer
'use client';
import { useState } from 'react';
import type { ProductsQuery } from '@usethrottle/catalog';
import { useProducts } from '@usethrottle/catalog/react';

// Inside a <CatalogProvider>. A new filter or sort keeps the last grid on
// screen while the next one loads, instead of flashing empty.
export function FilteredGrid({ category }: { category: string }) {
  const [sort, setSort] = useState<'relevance' | 'price_asc'>('relevance');
  const { data, dataFor, error, isPreviousData, isStale, refetch } = useProducts(
    { category, sort, limit: 24 },
    { keepPreviousData: true },
  );

  return (
    <section aria-busy={isPreviousData}>
      <select
        value={sort}
        onChange={(event) => setSort(event.target.value as typeof sort)}
      >
        <option value="relevance">Relevance</option>
        <option value="price_asc">Price, low to high</option>
      </select>
      {/* dataFor (typed unknown) is the query the shown data answers, not the
          one just asked. */}
      <p>Sorted by {(dataFor as ProductsQuery).sort ?? 'relevance'}</p>
      {error && (isPreviousData || isStale) ? (
        <button onClick={() => void refetch()}>
          Prices may be out of date. Retry
        </button>
      ) : null}
      <ul>
        {data?.products.map((product) => (
          <li key={product.handle}>{product.title ?? product.handle}</li>
        ))}
      </ul>
    </section>
  );
}
  • isPreviousData is true while data is the old answer. isLoading stays false meanwhile.
  • dataFor is the params object the shown data answers: for useSuggest the query echo is dataFor.q. While data is undefined (a first load, or a new key without keepPreviousData) it is the params just asked for, so check data before treating dataFor as its params.
  • If the new key fails, error is set and the old data stays, with isPreviousData still true. isStale is for revalidation failures of the data's own key.
  • Data is never kept across a customerKey change: one customer's answer is not shown to the next.

Refresh errors

isStale is true while the shown data was not confirmed by the latest attempt: a revalidation failed, or an initialData seed under a customerKey is still being revalidated. A failed revalidation keeps the data and sets error. A success clears both.

Prices that may be out of date
'use client';
import type { StorefrontListAnswer } from '@usethrottle/catalog';
import { useProducts } from '@usethrottle/catalog/react';

// Inside a <CatalogProvider>. Signed in, initialData is the anonymous server
// answer. If revalidating it fails, its list prices stay on screen and
// isStale says they may be out of date.
export function Prices({ initialData }: { initialData: StorefrontListAnswer }) {
  const { data, error, isStale, refetch } = useProducts(
    { limit: 24 },
    { initialData },
  );

  return (
    <>
      {isStale && error ? (
        <button onClick={() => void refetch()}>
          Prices may be out of date. Retry
        </button>
      ) : null}
      <ul>
        {data?.products.map((product) => (
          <li key={product.handle}>{product.title ?? product.handle}</li>
        ))}
      </ul>
    </>
  );
}
  • Retries. The hook retries by itself only when the failure is retryable: a NetworkError, a TimeoutError or a 5xx. The retries come 2 seconds after the failure and again 10 seconds after that, so two retries.
  • Other 4xx answers are not retried, and a 429 keeps its pause with no extra retry. refetch() always tries again.

The cache

  • Stale-while-revalidate. A cached answer shows at once. It is fetched again when a hook mounts and it is older than 60 seconds (staleTimeMs), and the refresh sends its ETag, so an unchanged answer costs a 304.
  • One request per key. Hooks asking for the same thing at once share one request.
  • The cache keeps the 200 most recently used answers.
  • Per customer. Keys include customerKey; a change of customer starts an empty cache.
  • Per provider. Two CatalogProviders, even with the same client, never share a cache.

Rate limits and indexing: a catalog 429 RATE_LIMITED from a read or a cart change pauses every read of the provider until its retry time, then each mounted query fetches once; while the catalog builds its first index, a hook fetches again once by itself. Details are on Errors and rate limits.

initialData: hand over a server answer

Pass an answer fetched on the server as initialData. The hook shows it at once and treats it as fetched at mount, so it does not fetch again until it is older than staleTimeMs. Under a signed-in customer (a customerKey) it is shown and fetched again at once, because a server render is usually anonymous: the customer's prices replace it after one request (0.1.1 and later). Render the server side with the customer's token to avoid showing regular prices first.

A server-rendered first page
'use client';
import type { StorefrontListAnswer } from '@usethrottle/catalog';
import { useProducts } from '@usethrottle/catalog/react';

// The server rendered the first page with a server key and passed its answer
// down. The hook shows it at once, as fetched now, and fetches only when it
// is older than staleTimeMs. initialData belongs to the query the hook
// mounted with: page 2 is fetched, never seeded from page 1.
export function CategoryPage({
  category,
  page,
  initial,
}: {
  category: string;
  page: number;
  initial: StorefrontListAnswer;
}) {
  const { data } = useProducts(
    { category, page, limit: 24 },
    { initialData: initial, staleTimeMs: 30_000 },
  );
  return (
    <ul>
      {data?.products.map((product) => (
        <li key={product.handle}>{product.title}</li>
      ))}
    </ul>
  );
}
  • It applies only to the query the hook mounted with. When the query changes (another page, another search) the hook fetches; the old answer is never shown as the new one. A hook mounted with enabled: false does not use it at all.
  • It is kept for the customerKey the hook mounted under. A server answer carries public prices (servers use a server key and no customer token), so with customer prices pass it only while the shopper is signed out.

useCart

useCart({ currency?, locale? }) is the provider's one cart, backed by Throttle's cart session. It answers the state and the actions:

FieldMeaning
cartThrottle's cart (cartId, lineItems, subtotal, total, unitCount, currency), or null.
sessionId, cartUrlThe cart session and its hosted cart page, or null.
statusidle, loading (a stored cart is being read), ready or error.
errorThe CatalogError the last action met.
pendingA change is queued or running.
add(sku, quantity = 1)Creates the cart on the first add, else adds to it.
update(lineId, quantity), remove(lineId)Change a line.
refresh()Prices every line again (after a sign-in, for example).
checkout(input?)Answers { checkoutUrl }, with cartUrls unless given { returnUrl, cancelUrl }. Never navigates.
switchCurrency(currency)A new cart with the same lines in that currency.
clear()Forgets the cart, with no request.
A product button and a cart page
'use client';
import { useCart } from '@usethrottle/catalog/react';

// Inside a <CatalogProvider> with cartUrls. Every useCart under one provider
// shares one cart, and its changes run one at a time, in order.
export function AddToCart({ sku }: { sku: string }) {
  const { add, pending } = useCart();
  // The first add creates the cart; later adds go to Throttle's cart session.
  return (
    <button disabled={pending} onClick={() => void add(sku, 1).catch(() => {})}>
      Add to cart
    </button>
  );
}

export function CartPage() {
  const { cart, cartUrl, status, error, pending, update, remove, checkout, switchCurrency } =
    useCart({ currency: 'USD' });

  if (status === 'loading') return <p>Loading your cart…</p>;
  if (cart === null) return <p>Your cart is empty.</p>;

  const goToCheckout = async () => {
    // Uses the provider's cartUrls. It answers the URL and never navigates.
    const { checkoutUrl } = await checkout();
    location.assign(checkoutUrl);
  };

  return (
    <section aria-busy={pending}>
      {error?.code === 'cart_prices_changed' ? (
        <p>Some prices changed. Check your cart, then check out again.</p>
      ) : null}
      <ul>
        {cart.lineItems?.map((line) => (
          <li key={line.id}>
            {line.name} × {line.quantity}
            <button onClick={() => void update(line.id, line.quantity + 1).catch(() => {})}>
              +
            </button>
            <button onClick={() => void remove(line.id).catch(() => {})}>Remove</button>
          </li>
        ))}
      </ul>
      <p>
        {cart.unitCount} items, {cart.subtotal} {cart.currency} (minor units)
      </p>
      {/* A new cart with the same lines, priced in EUR (BC4). */}
      <button onClick={() => void switchCurrency('EUR').catch(() => {})}>Pay in EUR</button>
      <button disabled={pending} onClick={() => void goToCheckout().catch(() => {})}>
        Checkout
      </button>
      {/* Or Throttle's hosted cart page, which has its own checkout button. */}
      {cartUrl ? <a href={cartUrl}>Open the hosted cart</a> : null}
    </section>
  );
}
  • Every useCart under one provider shares the cart. Changes run one at a time, in call order; each action rejects with the error it met and also sets error.
  • Nothing is read or requested until the hook mounts in the browser, so a server render never touches cartStorage.
  • Key a persistent cartStorage by customer, as the provider example does with localStorage. The hook follows the customer:
    • The provider usually mounts as a guest while @usethrottle/auth restores the session. When the customer then resolves, the hook loads that customer's saved cart from cartStorage.
    • If the guest already had a cart, that cart is kept and moves to the customer (its id is written under the customer and the guest entry removed); Throttle's sign-in claim merges the customer's older cart into it (see signing in with a cart).
    • On a sign-out or a switch from customer A to B, A's saved entry is removed and the incoming identity's own saved cart loads. One customer never gets another's cart.
    The hook cannot see a sign-out in another tab, so a storage slot shared by every customer could hand one shopper's cart to the next. Make a new adapter object per customer (as useMemo does in the example), never one stable object that reads the current customer when called: a drop removes the outgoing customer's entry through the outgoing object, so such an adapter would delete the incoming customer's cart instead.
  • A cartStorage that throws (localStorage in some private windows) surfaces as CatalogError CLIENT_ERROR; the cart keeps working in memory.

Customer carts

With @usethrottle/auth 0.12.0 or later, a signed-in customer's open cart comes back on any device. Pass customerCart to the provider. It is structural, so the SDK never holds a Throttle key and never imports @usethrottle/auth; the example uses account.getOpenCart and whenClaimSettled from that package.

Resume the customer's cart on any device
'use client';
import { useMemo, type ReactNode } from 'react';
import { useAuth } from '@usethrottle/auth/react';
import { createCatalogClient } from '@usethrottle/catalog';
import { CatalogProvider } from '@usethrottle/catalog/react';

// A currency must be known for the lookup: set it on the client (or pass it
// to useCart). Without one, useCart makes no lookup and the first add
// creates a cart, as before.
const client = createCatalogClient({
  publishableKey: 'pc_pk_your_browser_key',
  keyInQuery: true,
  currency: 'USD',
});

// Needs @usethrottle/auth 0.12.0 or later.
export function CustomerCartProvider({ children }: { children: ReactNode }) {
  const { auth, account, customer } = useAuth();
  // Memoised: a new object on every render would re-render every consumer.
  const customerCart = useMemo(
    () => ({
      // The customer's most recently updated open cart in `currency`, or null.
      getOpenCart: ({ currency }: { currency: string }) =>
        account.getOpenCart?.({ currency }) ?? Promise.resolve(null),
      // Resolves once the sign-in's guest-cart claim is done.
      whenClaimSettled: () => auth.whenClaimSettled?.() ?? Promise.resolve(),
    }),
    [auth, account],
  );

  return (
    <CatalogProvider
      client={client}
      customerKey={customer?.id ?? null}
      customerCart={customerCart}
    >
      {children}
    </CatalogProvider>
  );
}
  • useCart looks the cart up once per visit when the slot is empty (or ended), and before it would create a new cart.
  • It resumes only when a currency is known. Set currency on the client or pass it to the hook, useCart({ currency: 'USD' }). With neither, there is no lookup and the first add creates a cart as before. A found cart in another currency is not adopted. Without customerCart nothing changes from 1.1.0.
  • Known limits: a write within 30 seconds of a read that predates another device's merge reopens the emptied cart, and so can a write after a re-read that failed. The lookup runs once per visit, so a currency known only after mount does not trigger it (the first add finds the cart instead). Pass one currency to every useCart call site, or the lookup and the create can differ. switchCurrency while signed in makes a cart in the new currency without a lookup, so a customer with an open cart in that currency on another device can end up with a second one.

Return URLs, the sign-in merge and every cart error are on Cart and checkout.

useSubmitReview

useSubmitReview(handle) answers { status, error, result, submit, reset }. status is idle, submitting, done or error; result is the answer, { review: { id, status } }.

A review form
'use client';
import { useState } from 'react';
import { useSubmitReview } from '@usethrottle/catalog/react';

// Inside a <CatalogProvider> whose client sends the customer token.
export function ReviewForm({ handle }: { handle: string }) {
  const [rating, setRating] = useState(5);
  const [body, setBody] = useState('');
  const { submit, status, error, result, reset } = useSubmitReview(handle);

  if (status === 'done') {
    return (
      <p>
        {result?.review.status === 'pending'
          ? 'Thanks! Your review appears once the store approves it.'
          : 'Thanks for your review!'}{' '}
        <button onClick={reset}>Close</button>
      </p>
    );
  }

  return (
    <form
      onSubmit={(event) => {
        event.preventDefault();
        // A retry after a failure reuses the submission's idempotency key,
        // so a lost answer never makes a second review.
        void submit({ rating, body, displayName: 'Sam' }).catch(() => {});
      }}
    >
      <select value={rating} onChange={(event) => setRating(Number(event.target.value))}>
        {[5, 4, 3, 2, 1].map((stars) => (
          <option key={stars} value={stars}>
            {stars} stars
          </option>
        ))}
      </select>
      <textarea value={body} onChange={(event) => setBody(event.target.value)} />
      {error ? <p role="alert">{error.code}</p> : null}
      <button disabled={status === 'submitting'}>Submit review</button>
    </form>
  );
}
  • Each submission has one idempotency key. A retry after a failure reuses it, so a lost answer never makes a second review; reset() starts a new submission.
  • A second submit while one is in flight returns the first, with no new request.
  • A 429 from a submit (the per-customer limit, the per-IP review cap, REVIEW_QUEUE_FULL) is only that submission's error: it never pauses the provider's reads. A pause a read started still refuses a submit, with no request.
Escape review text
Render review text as text, which JSX does. See Product reviews for the rules and errors.