TrackIt
TrackIt
Contact us
Blogs

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# .env
2VITE_SHOPIFY_STORE_DOMAIN=your-store.myshopify.com
3VITE_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.ts
2import { createCartActionHandler } from "@trackit.io/shopify-cart/server";
3
4const storeDomain = import.meta.env.VITE_SHOPIFY_STORE_DOMAIN;
5const storefrontAccessToken =
6  import.meta.env.VITE_SHOPIFY_STOREFRONT_ACCESS_TOKEN;
7
8export 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.tsx
2import { useState, useCallback } from "react";
3import { CartProvider } from "@trackit.io/shopify-cart/react";
4import { cartAction } from "./api";
5
6const CART_KEY = "shopify-cart-id";
7
8export default function App() {
9  const [cartId, setCartId] = useState<string | null>(() =>
10    localStorage.getItem(CART_KEY),
11  );
12
13  const handleCartIdChange = useCallback((id: string | null) => {
14    setCartId(id);
15    if (id) localStorage.setItem(CART_KEY, id);
16    else localStorage.removeItem(CART_KEY);
17  }, []);
18
19  return (
20    <CartProvider
21      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 handler
2import { createStorefrontClient } from "@trackit.io/shopify-cart";
3
4const storefront = createStorefrontClient({
5  storeDomain,
6  storefrontAccessToken,
7});
8
9export 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";
2
3function Shop() {
4  const { cart, isLoading, isSyncing, updateLineQuantity, removeLine } =
5    useCart();
6
7  if (isLoading) return <p>Loading cart…</p>;
8
9  return (
10    <>
11      <header>
12        {cart.totalQuantity} items, {cart.subtotal.amount}{" "}
13        {cart.subtotal.currencyCode}
14        {isSyncing && <span> syncing…</span>}
15      </header>
16
17      {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();
2
3const variant = product.variants.edges[0].node;
4
5addLine(variant.id, {
6  title: product.title,
7  variantTitle: variant.title,
8  price: variant.price,
9  image: product.featuredImage
10    ? { 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();
2
3<button
4  disabled={!cart.checkoutUrl || cart.lines.length === 0}
5  onClick={() => {
6    window.location.href = cart.checkoutUrl!;
7  }}
8>
9  Checkout
10</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.