Set Up a Shopify Cart in Your React App in 10 Minutes
Author
Alexandre Coelho
Date Published
The problem
A shopping cart is one of the simpler parts of an ecommerce app. Customers add products, adjust quantities, remove items, and eventually check out. In a traditional Shopify theme using liquid, none of that requires much thought, because the cart and the storefront are the same application. In a headless setup they are not. The cart lives in Shopify, the interface lives in the React app, and every change a customer makes has to travel between the two. Keeping the cart responsive while staying synchronized with Shopify is where the work actually is.
We keep two storefronts running against Shopify: a React Router web app and an Expo app. Both need a cart, both need it to behave the same way, and that is where the trouble starts.
Shopify’s Storefront API treats the cart as a GraphQL resource, so every change requires a network round trip. Adding a line is a mutation. Changing a quantity from 1 to 2 is another one. Removing it is another. If these operations are wired up directly, the UI has to wait for the server before reflecting each change, which can make the cart feel sluggish.
Rapid interactions introduce another problem. A customer who taps + five times in a row fires five mutations that race each other. The final quantity then depends on the order in which those requests are processed and returned. They might have wanted six, but the cart ends up with three. Refreshing the page can cause another problem: if the cart ID only exists in React state, the cart is lost entirely after the refresh.
Solving these problems once is manageable. Solving them across two codebases, each with its own approach to storing the cart ID, can lead to subtle differences in behavior and bugs that are difficult to reproduce. So we pulled the whole thing out into @trackit.io/shopify-cart, which is the library this article is based on.
What’s in the box
The library focuses on cart management and provides:
- optimistic updates, with automatic rollback when the server rejects a change
- debouncing and batching, so multiple rapid changes can be combined into a single request
- a queue that prevents cart mutations from overlapping
- support for every cart operation exposed by the Storefront API, including discount codes, gift cards, delivery options, attributes, metafields, and buyer identity
- TypeScript types throughout
Two elements are deliberately left out. The package does not include components or styles, so the markup and presentation remain under the application's control. The repository includes five example apps (Vite, Next.js, React Router, TanStack Start and Expo) that demonstrate how the library can be integrated. Checkout also remains with Shopify. The cart exposes a checkoutUrl, which can be used to send the customer to Shopify's checkout.
Before you start
A React app, a Shopify store, and a Storefront API access token are required. The examples in this article use Vite.
The Storefront API access token is safe to include in a browser bundle. It is designed for client-side use, and Shopify’s own Hydrogen framework makes the token available to the client. The token can only be used with the Storefront API. An Admin API token is different: it can grant access to sensitive store data and administrative operations, so it must never be exposed to the client.
1# .env2VITE_SHOPIFY_STORE_DOMAIN=your-store.myshopify.com3VITE_SHOPIFY_STOREFRONT_ACCESS_TOKEN=your-storefront-access-token
Step 1: Install
1npm install @trackit.io/shopify-cart
Step 2: Create the action handler
Every cart operation goes through a single function defined in the application. Because the Storefront API token is safe to use in a browser, this function can run on the client and call Shopify directly, without an API route in between.
1// src/api.ts2import { createCartActionHandler } from "@trackit.io/shopify-cart/server";34const storeDomain = import.meta.env.VITE_SHOPIFY_STORE_DOMAIN;5const storefrontAccessToken =6 import.meta.env.VITE_SHOPIFY_STOREFRONT_ACCESS_TOKEN;78export const cartAction = createCartActionHandler({9 storeDomain,10 storefrontAccessToken,11});12
The Direct pattern: the browser communicates directly with Shopify without a backend in between.
The /server import path comes from the way the package entry points are organized. The module does not depend on Node and can run in a browser. If the token needs to remain on the server, the same handler can be used inside a Next.js Server Action or route handler without changing the rest of the implementation.
Step 3: Wrap your app in the provider
1// src/App.tsx2import { useState, useCallback } from "react";3import { CartProvider } from "@trackit.io/shopify-cart/react";4import { cartAction } from "./api";56const CART_KEY = "shopify-cart-id";78export default function App() {9 const [cartId, setCartId] = useState<string | null>(() =>10 localStorage.getItem(CART_KEY),11 );1213 const handleCartIdChange = useCallback((id: string | null) => {14 setCartId(id);15 if (id) localStorage.setItem(CART_KEY, id);16 else localStorage.removeItem(CART_KEY);17 }, []);1819 return (20 <CartProvider21 cartId={cartId}22 onCartIdChange={handleCartIdChange}23 onAction={cartAction}24 >25 <Shop />26 </CartProvider>27 );28}
The provider does not manage storage. Instead, the application owns the cart ID and the provider reports when it changes. This allows the same component tree to use localStorage in the browser, AsyncStorage in Expo, or a cookie in a server-rendered application without changing the provider.
The provider also handles page refreshes. When given a cartId without an initialCart, it fetches the cart when it mounts and exposes isLoading while the request is in progress. If Shopify has expired the cart, it calls onCartIdChange(null), allowing the application to remove the expired ID from storage.
Step 4: Fetch some products
Product data is separate from cart management, so the package provides a typed Storefront client for fetching products:
1// src/api.ts, alongside the cart handler2import { createStorefrontClient } from "@trackit.io/shopify-cart";34const storefront = createStorefrontClient({5 storeDomain,6 storefrontAccessToken,7});89export async function fetchProducts() {10 const { products } = await storefront.GetProducts({ first: 10 });11 return products.edges.map((edge) => edge.node);12}
fetchProducts can be used from a useEffect, a route loader, or the application's existing data-fetching layer. The return type comes directly from the generated GraphQL types, so the available fields are fully typed and available through autocomplete.
Step 5: Use the cart
1import { useCart } from "@trackit.io/shopify-cart/react";23function Shop() {4 const { cart, isLoading, isSyncing, updateLineQuantity, removeLine } =5 useCart();67 if (isLoading) return <p>Loading cart…</p>;89 return (10 <>11 <header>12 {cart.totalQuantity} items, {cart.subtotal.amount}{" "}13 {cart.subtotal.currencyCode}14 {isSyncing && <span> syncing…</span>}15 </header>1617 {cart.lines.map((line) => (18 <div key={line.id} style={{ opacity: line.isPending ? 0.5 : 1 }}>19 {line.title} × {line.quantity}20 <button onClick={() => updateLineQuantity(line.id, line.quantity + 1)}>21 +22 </button>23 <button onClick={() => updateLineQuantity(line.id, line.quantity - 1)}>24 −25 </button>26 <button onClick={() => removeLine(line.id)}>Remove</button>27 </div>28 ))}29 </>30 );31}32
Adding a product follows the same pattern. Each product has one or more variants, and the cart uses the variant ID rather than the product ID:
1const { addLine } = useCart();23const variant = product.variants.edges[0].node;45addLine(variant.id, {6 title: product.title,7 variantTitle: variant.title,8 price: variant.price,9 image: product.featuredImage10 ? { url: product.featuredImage.url, altText: product.title }11 : undefined,12});
The second argument may seem unnecessary because Shopify already has the product title and price. It provides the information needed to render the new line immediately, before the request reaches Shopify. Without it, the library would have to wait for the server response before it could render the newly added line, reintroducing the delay that the optimistic update is designed to avoid.
There is an important distinction with isPending. It applies only to lines that exist in the optimistic layer, meaning newly added items that Shopify has not yet confirmed. Increasing the quantity of an existing line does not set isPending; the quantity updates immediately. As a result, the opacity example above dims newly added items but has no effect on quantity changes.
For a general indicator that an operation is in progress, isSyncing can be used instead. pendingCount provides the number of outstanding operations.
Step 6: Checkout
1const { cart } = useCart();23<button4 disabled={!cart.checkoutUrl || cart.lines.length === 0}5 onClick={() => {6 window.location.href = cart.checkoutUrl!;7 }}8>9 Checkout10</button>;
At this point, the cart is fully integrated and ready to send customers to Shopify checkout.
What just happened
Three mechanisms are responsible for the behavior described above. Together, they handle the synchronization between the local UI state and Shopify.
Debouncing is keyed per line. Five clicks on + result in a single mutation, sent 300ms after the last click.
Because the timers are stored per line ID, updates to two different lines can happen independently.
The queue processes operations one at a time, with each operation waiting for the previous one to resolve. This prevents multiple mutations from racing each other and producing an unexpected final quantity.
Rollback works by reconstruction rather than by reversing the previous change. The UI state is derived on each change from the latest server response and a map of operations that are still in flight. When a mutation fails, its entry is removed from that map and the state is recomputed without it. The optimistic change is therefore removed automatically, leaving the UI consistent with the state held by Shopify. The error is exposed separately through onError.
Key takeaways
- The cart is a network resource, so a headless implementation needs to account for synchronization through debouncing, queuing, and optimistic updates.
- Cart ID persistence belongs outside the provider. Keeping cartId and onCartIdChange in the application makes the same component tree portable across localStorage, AsyncStorage, and cookies.
- Deriving UI state from server state plus pending operations simplifies rollback. A failed operation can be removed and the state recomputed rather than explicitly reversed.
- isPending applies only to optimistic-only lines, not to every operation that is still in progress.
- Checkout remains with Shopify. The cart's responsibility ends at checkoutUrl.
Where to go next
The demos are available at shopify-cart.trackit.io, and the repository includes runnable examples for Next.js App Router, React Router v7, TanStack Start, Expo, and the Vite setup used above. The server-rendered examples preload the cart on the server, avoiding a flash of an empty cart during hydration.
One detail not covered above is applyCode(). It accepts either a discount code or a gift card and determines which type it is automatically. The Storefront API does not provide a way to determine this directly, so the handler first applies the code as a discount, checks whether it was accepted, and falls back to adding it as a gift card. The same method works for both cases, without requiring additional branching in the UI.


