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.
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
- Re-renders: Any state change re-renders all consumers
- No selectors: Can’t subscribe to just part of the state
- Wrapper required: Need to wrap components in Provider
Option 2: Zustand (Recommended)
Zustand is a small, fast state management library. It’s our recommended choice.
npm install zustandimport { 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 changefunction 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
- Selective subscriptions: Components only re-render when their selected state changes
- No provider needed: Works anywhere, even outside React
- Tiny bundle: ~1KB gzipped
- Simple API: No boilerplate, just functions
Option 3: Jotai
Jotai uses an atomic model—each piece of state is independent.
npm install jotaiimport { 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;});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
- Atomic model: Fine-grained updates, very efficient
- Derived state: Easy computed values
- Suspense support: Built-in async handling
- Tiny bundle: ~2KB gzipped
Comparison Table
| Feature | Context | Zustand | Jotai |
|---|---|---|---|
| Bundle Size | 0 KB | ~1 KB | ~2 KB |
| Learning Curve | Low | Low | Medium |
| Re-render Control | Poor | Good | Excellent |
| DevTools | React DevTools | Zustand DevTools | Jotai DevTools |
| Works Outside React | No | Yes | No |
| Async Actions | Manual | Built-in | Built-in |
| Best For | Simple apps | Most apps | Complex derived state |
Our Recommendation: Zustand
For a Shopify theme, we recommend Zustand because:
- Simple mental model: Just a store with state and actions
- Great performance: Selective subscriptions out of the box
- Works everywhere: Can access cart state from non-React code if needed
- 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:
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
- React Context works for simple cases but causes unnecessary re-renders
- Zustand is our recommendation—simple, performant, and flexible
- Jotai excels at fine-grained reactivity and derived state
- All three can work with Shopify’s data through the Liquid bridge
- Initialize state from
window.Shopifyfor 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...