State Management and Shopify APIs Intermediate 10 min read

Choosing a State Solution: Context vs Zustand vs Jotai

Compare React Context, Zustand, and Jotai for state management in your Shopify theme. Learn when to use each and how to set them up.

Your React components need to share state—cart contents, selected variants, UI states like open drawers. In this lesson, we’ll compare three solutions and help you choose the right one for your Shopify theme.

The State Management Challenge

In a Shopify theme, multiple independent React “islands” need to share state:

┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ Header │ │ Product │ │ Cart │
│ Cart Icon │ ◄──►│ Form │ ◄──►│ Drawer │
│ (count: 3) │ │ (Add to cart) │ │(items, totals)│
└───────────────┘ └───────────────┘ └───────────────┘
│ │ │
└─────────────────────┼─────────────────────┘
┌───────▼───────┐
│ Shared Cart │
│ State │
└───────────────┘

Let’s compare three approaches.

Option 1: React Context

React’s built-in solution. Good for simple cases but has limitations.

src/context/CartContext.tsx
import { createContext, useContext, useState, useCallback, ReactNode } from 'react';
import type { Cart, CartLineItem } from '@/types';
interface CartContextValue {
cart: Cart | null;
isLoading: boolean;
isOpen: boolean;
addItem: (variantId: number, quantity: number) => Promise<void>;
updateItem: (key: string, quantity: number) => Promise<void>;
removeItem: (key: string) => Promise<void>;
openCart: () => void;
closeCart: () => void;
}
const CartContext = createContext<CartContextValue | null>(null);
export function CartProvider({ children }: { children: ReactNode }) {
const [cart, setCart] = useState<Cart | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const addItem = useCallback(async (variantId: number, quantity: number) => {
setIsLoading(true);
try {
const response = await fetch('/cart/add.js', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: variantId, quantity }),
});
const updatedCart = await fetch('/cart.js').then((res) => res.json());
setCart(updatedCart);
setIsOpen(true);
} finally {
setIsLoading(false);
}
}, []);
const updateItem = useCallback(async (key: string, quantity: number) => {
setIsLoading(true);
try {
await fetch('/cart/change.js', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: key, quantity }),
});
const updatedCart = await fetch('/cart.js').then((res) => res.json());
setCart(updatedCart);
} finally {
setIsLoading(false);
}
}, []);
const removeItem = useCallback(
async (key: string) => {
await updateItem(key, 0);
},
[updateItem]
);
const openCart = useCallback(() => setIsOpen(true), []);
const closeCart = useCallback(() => setIsOpen(false), []);
return (
<CartContext.Provider
value={{
cart,
isLoading,
isOpen,
addItem,
updateItem,
removeItem,
openCart,
closeCart,
}}
>
{children}
</CartContext.Provider>
);
}
export function useCart() {
const context = useContext(CartContext);
if (!context) {
throw new Error('useCart must be used within a CartProvider');
}
return context;
}

Usage:

function AddToCartButton({ variantId }: { variantId: number }) {
const { addItem, isLoading } = useCart();
return (
<button onClick={() => addItem(variantId, 1)} disabled={isLoading}>
{isLoading ? 'Adding...' : 'Add to Cart'}
</button>
);
}

Context Limitations

  1. Re-renders: Any state change re-renders all consumers
  2. No selectors: Can’t subscribe to just part of the state
  3. Wrapper required: Need to wrap components in Provider

Zustand is a small, fast state management library. It’s our recommended choice.

Terminal window
npm install zustand
src/stores/cart.ts
import { create } from 'zustand';
import type { Cart } from '@/types';
/*
* Zustand Store Pattern
*
* create() returns a hook (useCart). The callback receives:
* - set: function to update state (triggers re-renders)
* - get: function to read current state (doesn't trigger re-renders)
*
* Components subscribe selectively: useCart(state => state.cart)
* Only re-renders when that specific slice changes!
*/
interface CartState {
// State - all data the store manages
cart: Cart | null;
isLoading: boolean; // For loading spinners during API calls
isOpen: boolean; // Cart drawer visibility
// Actions - functions that modify state
setCart: (cart: Cart) => void;
addItem: (variantId: number, quantity: number) => Promise<void>;
updateItem: (key: string, quantity: number) => Promise<void>;
removeItem: (key: string) => Promise<void>;
openCart: () => void;
closeCart: () => void;
toggleCart: () => void;
}
export const useCart = create<CartState>((set, get) => ({
// Initial state values
cart: null,
isLoading: false,
isOpen: false,
// Simple setters - one-liner state updates
setCart: (cart) => set({ cart }),
openCart: () => set({ isOpen: true }),
closeCart: () => set({ isOpen: false }),
// Callback form of set() to access current state
toggleCart: () => set((state) => ({ isOpen: !state.isOpen })),
// Async action: add item to cart via Shopify AJAX API
addItem: async (variantId, quantity) => {
set({ isLoading: true }); // Show loading state
try {
// Shopify's cart add endpoint
await fetch('/cart/add.js', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: variantId, quantity }),
});
// Fetch updated cart to get new totals
const cartResponse = await fetch('/cart.js');
const cart = await cartResponse.json();
// Update state and open drawer to show added item
set({ cart, isOpen: true });
} catch (error) {
console.error('Failed to add item:', error);
throw error; // Re-throw so caller can handle
} finally {
// Always clear loading state, even on error
set({ isLoading: false });
}
},
// Update quantity of existing cart item
updateItem: async (key, quantity) => {
set({ isLoading: true });
try {
// Shopify's cart change endpoint
await fetch('/cart/change.js', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: key, quantity }),
});
const cartResponse = await fetch('/cart.js');
const cart = await cartResponse.json();
set({ cart });
} catch (error) {
console.error('Failed to update item:', error);
throw error;
} finally {
set({ isLoading: false });
}
},
// Remove = update quantity to 0
// get() accesses current state to call another action
removeItem: async (key) => {
await get().updateItem(key, 0);
},
}));

Usage:

/*
* Selective Subscriptions: The Key Zustand Advantage
*
* Each useCart() call with a selector only triggers re-render
* when THAT specific piece of state changes. This is why Zustand
* is more performant than Context (which re-renders all consumers).
*/
// Only re-renders when itemCount changes (not when isOpen changes)
function CartIcon() {
// Selector extracts just the data this component needs
const itemCount = useCart((state) => state.cart?.itemCount ?? 0);
const openCart = useCart((state) => state.openCart);
return <button onClick={openCart}>Cart ({itemCount})</button>;
}
// Only re-renders when isOpen or items change
function CartDrawer() {
const isOpen = useCart((state) => state.isOpen);
const closeCart = useCart((state) => state.closeCart);
const items = useCart((state) => state.cart?.items ?? []);
// Early return if closed - component is still subscribed but renders nothing
if (!isOpen) return null;
return (
<div className="cart-drawer">
{items.map((item) => (
<CartItem key={item.key} item={item} />
))}
<button onClick={closeCart}>Close</button>
</div>
);
}

Zustand Benefits

  1. Selective subscriptions: Components only re-render when their selected state changes
  2. No provider needed: Works anywhere, even outside React
  3. Tiny bundle: ~1KB gzipped
  4. Simple API: No boilerplate, just functions

Option 3: Jotai

Jotai uses an atomic model—each piece of state is independent.

Terminal window
npm install jotai
src/stores/atoms.ts
import { atom } from 'jotai';
import type { Cart } from '@/types';
// Base atoms (individual pieces of state)
export const cartAtom = atom<Cart | null>(null);
export const cartLoadingAtom = atom(false);
export const cartOpenAtom = atom(false);
// Derived atom (computed from other atoms)
export const cartItemCountAtom = atom((get) => {
const cart = get(cartAtom);
return cart?.itemCount ?? 0;
});
export const cartTotalAtom = atom((get) => {
const cart = get(cartAtom);
return cart?.totalPrice ?? 0;
});
src/stores/cart-actions.ts
import { useAtom, useSetAtom } from 'jotai';
import { cartAtom, cartLoadingAtom, cartOpenAtom } from './atoms';
export function useCartActions() {
const setCart = useSetAtom(cartAtom);
const setLoading = useSetAtom(cartLoadingAtom);
const setOpen = useSetAtom(cartOpenAtom);
const addItem = async (variantId: number, quantity: number) => {
setLoading(true);
try {
await fetch('/cart/add.js', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: variantId, quantity }),
});
const cartResponse = await fetch('/cart.js');
const cart = await cartResponse.json();
setCart(cart);
setOpen(true);
} finally {
setLoading(false);
}
};
return { addItem };
}

Usage:

import { useAtomValue } from 'jotai';
import { cartItemCountAtom, cartOpenAtom } from '@/stores/atoms';
function CartIcon() {
const itemCount = useAtomValue(cartItemCountAtom);
const [isOpen, setIsOpen] = useAtom(cartOpenAtom);
return <button onClick={() => setIsOpen(true)}>Cart ({itemCount})</button>;
}

Jotai Benefits

  1. Atomic model: Fine-grained updates, very efficient
  2. Derived state: Easy computed values
  3. Suspense support: Built-in async handling
  4. Tiny bundle: ~2KB gzipped

Comparison Table

FeatureContextZustandJotai
Bundle Size0 KB~1 KB~2 KB
Learning CurveLowLowMedium
Re-render ControlPoorGoodExcellent
DevToolsReact DevToolsZustand DevToolsJotai DevTools
Works Outside ReactNoYesNo
Async ActionsManualBuilt-inBuilt-in
Best ForSimple appsMost appsComplex derived state

Our Recommendation: Zustand

For a Shopify theme, we recommend Zustand because:

  1. Simple mental model: Just a store with state and actions
  2. Great performance: Selective subscriptions out of the box
  3. Works everywhere: Can access cart state from non-React code if needed
  4. Middleware support: Easy to add persistence, devtools, etc.

Zustand Middleware Examples

Persist to localStorage:

import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export const useCart = create(
persist<CartState>(
(set, get) => ({
// ... state and actions
}),
{
name: 'cart-storage',
}
)
);

Add DevTools:

import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
export const useCart = create(
devtools<CartState>(
(set, get) => ({
// ... state and actions
}),
{ name: 'CartStore' }
)
);

Initializing State from Liquid

Load initial state from Liquid-provided data:

src/stores/cart.ts
export const useCart = create<CartState>((set, get) => ({
// Initialize from global Shopify object
cart: window.Shopify?.cart ?? null,
isLoading: false,
isOpen: false,
// ... actions
}));
{%- comment -%} In theme.liquid {%- endcomment -%}
<script>
window.Shopify = window.Shopify || {};
window.Shopify.cart = {{ cart | json }};
</script>

Key Takeaways

  1. React Context works for simple cases but causes unnecessary re-renders
  2. Zustand is our recommendation—simple, performant, and flexible
  3. Jotai excels at fine-grained reactivity and derived state
  4. All three can work with Shopify’s data through the Liquid bridge
  5. Initialize state from window.Shopify for instant hydration

In the next lesson, we’ll build a complete cart store using Zustand with Shopify’s AJAX API.

Finished this lesson?

Mark it complete to track your progress.

Discussion

Loading comments...