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
| Prop | Meaning |
|---|---|
client | A client from createCatalogClient. Required. |
customerKey | The signed-in customer's id, or null. It partitions the cache and the cart by customer. Required whenever customerToken can return a token. |
cartStorage | Where 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. |
throttleBaseUrl | Throttle's API, for the cart session. Default https://api.usethrottle.dev. |
throttleCheckoutUrl | Throttle's hosted pages, for a cart link the hook builds itself. Default https://checkout.usethrottle.dev. |
'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. PasscustomerTokenas a function. With@usethrottle/auth0.10.1 or later that is() => auth.getAccessToken(), read on every request. A new client per customer would start a new cart. - Change
customerKeyin the same update as the token. Reading the customer fromuseAuth(), 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 whenevercustomerTokencan 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: trueon 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
| Hook | Calls | data |
|---|---|---|
useProducts(query, options?) | products.list | Products, 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.values | Every value of one filter for a selection. |
useCategories({ depth? }) | categories.tree | The category tree. |
useCollection(handle) | collections.get | The collection page, or { redirectTo }. |
useSuggest(q, { debounceMs?, limit? }) | suggest | Suggestions. No request under 2 characters; one per pause in typing (200 ms by default). |
useReviews(handle, { sort?, rating?, limit?, cursor? }) | reviews.list | Reviews and their summary, or { redirectTo }. |
useConfig(options?) | config | Currencies, 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, orundefined.error: theCatalogErrorof the last attempt, orundefined. A failed refresh keeps the lastdata.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.
'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.
| Hook | Calls | data |
|---|---|---|
useTags({ q?, limit? }) | tags.list | { tags } |
useCollections({ limit?, cursor? }) | collections.list | { collections } |
useReviewEligibility(handle) | reviews.eligibility | Whether 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. |
'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.
'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>
);
}
-
isPreviousDataistruewhiledatais the old answer.isLoadingstaysfalsemeanwhile. -
dataForis the params object the showndataanswers: foruseSuggestthe query echo isdataFor.q. Whiledataisundefined(a first load, or a new key withoutkeepPreviousData) it is the params just asked for, so checkdatabefore treatingdataForas its params. -
If the new key fails,
erroris set and the old data stays, withisPreviousDatastilltrue.isStaleis for revalidation failures of the data's own key. -
Data is never kept across a
customerKeychange: 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.
'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, aTimeoutErroror 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
429keeps 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 a304. - 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.
'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: falsedoes not use it at all. -
It is kept for the
customerKeythe 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:
| Field | Meaning |
|---|---|
cart | Throttle's cart (cartId, lineItems, subtotal, total, unitCount, currency), or null. |
sessionId, cartUrl | The cart session and its hosted cart page, or null. |
status | idle, loading (a stored cart is being read), ready or error. |
error | The CatalogError the last action met. |
pending | A 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. |
'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
useCartunder one provider shares the cart. Changes run one at a time, in call order; each action rejects with the error it met and also setserror. -
Nothing is read or requested until the hook mounts in the browser, so a server render never touches
cartStorage. - Key a persistent
cartStorageby customer, as the provider example does withlocalStorage. The hook follows the customer:-
The provider usually mounts as a guest while
@usethrottle/authrestores the session. When the customer then resolves, the hook loads that customer's saved cart fromcartStorage. - 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.
useMemodoes 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. -
The provider usually mounts as a guest while
-
A
cartStoragethat throws (localStoragein some private windows) surfaces asCatalogErrorCLIENT_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.
'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>
);
}
-
useCartlooks 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
currencyon the client or pass it to the hook,useCart({ currency: 'USD' }). With neither, there is no lookup and the firstaddcreates a cart as before. A found cart in another currency is not adopted. WithoutcustomerCartnothing 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
addfinds the cart instead). Pass one currency to everyuseCartcall site, or the lookup and the create can differ.switchCurrencywhile 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 } }.
'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
submitwhile one is in flight returns the first, with no new request. -
A
429from 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.