In this guide, we will demonstrate how to implement cart controls on the single product page, which will take into account the state of the cart stored in the user session. This guide builds upon the app created in the previous guides, so use the code samples from them as a starting point. The guide is broken down into three parts: The implementation and use of UserSessionProvider.jsx
, useCartMutations.js
, and CartOptions.jsx
.
- Basic knowledge of React and React Router.
- Familiarity with GraphQL and WPGraphQL.
- A setup WPGraphQL/WooGraphQL backend.
- Read previous guides on Routing By URI and Using Product Data
import { gql } from '@apollo/client'; export const CustomerContent = gql` fragment CustomerContent on Customer { id sessionToken } `; export const ProductContentSlice = gql` fragment ProductContentSlice on Product { id databaseId name slug type image { id sourceUrl(size: WOOCOMMERCE_THUMBNAIL) altText } ... on SimpleProduct { price regularPrice soldIndividually } ... on VariableProduct { price regularPrice soldIndividually } } `; export const ProductVariationContentSlice = gql` fragment ProductVariationContentSlice on ProductVariation { id databaseId name slug image { id sourceUrl(size: WOOCOMMERCE_THUMBNAIL) altText } price regularPrice } `; export const ProductContentFull = gql` fragment ProductContentFull on Product { id databaseId slug name type description shortDescription(format: RAW) image { id sourceUrl altText } galleryImages { nodes { id sourceUrl(size: WOOCOMMERCE_THUMBNAIL) altText } } productTags(first: 20) { nodes { id slug name } } attributes { nodes { id attributeId ... on LocalProductAttribute { name options variation } ... on GlobalProductAttribute { name options variation } } } ... on SimpleProduct { onSale stockStatus price rawPrice: price(format: RAW) regularPrice salePrice stockStatus stockQuantity soldIndividually } ... on VariableProduct { onSale price rawPrice: price(format: RAW) regularPrice salePrice stockStatus stockQuantity soldIndividually variations(first: 50) { nodes { id databaseId name price rawPrice: price(format: RAW) regularPrice salePrice onSale attributes { nodes { name label value } } } } } } `; export const VariationContent = gql` fragment VariationContent on ProductVariation { id name slug price regularPrice salePrice stockStatus stockQuantity onSale image { id sourceUrl altText } } `; export const CartItemContent = gql` fragment CartItemContent on CartItem { key product { node { ...ProductContentSlice } } variation { node { ...ProductVariationContentSlice } } quantity total subtotal subtotalTax extraData { key value } } `; export const CartContent = gql` fragment CartContent on Cart { contents(first: 100) { itemCount nodes { ...CartItemContent } } appliedCoupons { code discountAmount discountTax } needsShippingAddress availableShippingMethods { packageDetails supportsShippingCalculator rates { id instanceId methodId label cost } } subtotal subtotalTax shippingTax shippingTotal total totalTax feeTax feeTotal discountTax discountTotal } `; export const GetProduct = gql` query GetProduct($id: ID!, $idType: ProductIdTypeEnum) { product(id: $id, idType: $idType) { ...ProductContentFull } } `; export const GetProductVariation = gql` query GetProductVariation($id: ID!) { productVariation(id: $id, idType: DATABASE_ID) { ...VariationContent } } `; export const GetCart = gql` query GetCart($customerId: Int) { cart { ...CartContent } customer(customerId: $customerId) { ...CustomerContent } } `; export const AddToCart = gql` mutation AddToCart($productId: Int!, $variationId: Int, $quantity: Int, $extraData: String) { addToCart( input: {productId: $productId, variationId: $variationId, quantity: $quantity, extraData: $extraData} ) { cart { ...CartContent } cartItem { ...CartItemContent } } } `; export const UpdateCartItemQuantities = gql` mutation UpdateCartItemQuantities($items: [CartItemQuantityInput]) { updateItemQuantities(input: {items: $items}) { cart { ...CartContent } items { ...CartItemContent } } } `; export const RemoveItemsFromCart = gql` mutation RemoveItemsFromCart($keys: [ID], $all: Boolean) { removeItemsFromCart(input: {keys: $keys, all: $all}) { cart { ...CartContent } cartItems { ...CartItemContent } } } `;
We've included all the queries will be using going forward and leveraging some fragments here and there. Now we can move onto implementing the components sourcing these queries and mutations. We won't go over them into much detail here but you can learn more about them in the schema docs.
UserSessionProvider.jsx
is a state manager that queries and maintains the app's copy of the end-user's session state from WooCommerce on the backend. We'll also be implementing a helper hook called useSession()
that will provide the user session state to components nested within the provider. In order for the UserSessionProvider
code in our samples to work properly, the end user will have to implement an ApolloClient with a middleware layer configured to manage the WooCommerce session token, like the one demonstrated in our Configuring GraphQL Client for User Session guide.
Here is the code for UserSessionProvider.jsx
:
import { createContext, useContext, useEffect, useReducer } from 'react'; import { useQuery } from '@apollo/client'; import { GetCart } from './graphql'; const initialSession = { cart: null, customer: null, }; export const SessionContext = createContext(initialSession); const reducer = (state, action) => { switch (action.type) { case 'SET_CART': return { ...state, cart: action.payload, }; case 'SET_CUSTOMER': return { ...state, customer: action.payload, }; default: throw new Error('Invalid action dispatched to session reducer'); } }; const { Provider } = SessionContext; export function SessionProvider({ children }) { const [state, dispatch] = useReducer(reducer, initialSession); const { data, loading: fetching } = useQuery(GetCart); useEffect(() => { if (data?.cart) { dispatch({ type: 'SET_CART', payload: data.cart, }); } if (data?.customer) { dispatch({ type: 'SET_CUSTOMER', payload: data.customer, }); } }, [data]); const setCart = (cart) => dispatch({ type: 'SET_CART', payload: cart, }); const setCustomer = (customer) => dispatch({ type: 'SET_CUSTOMER', payload: customer, }); const store = { ...state, fetching, setCart, setCustomer, }; return ( <Provider value={store}>{children}</Provider> ); } export const useSession = () => useContext(SessionContext);
To use the SessionProvider
, you should wrap your root app component with it and wrap the SessionProvider
with an ApolloProvider set with our session token managing ApolloClient. Make sure to demonstrate this for the reader against our previous code samples from previous posts.
useCartMutations.js
is a hook that, when provided a product ID
, variation ID
, and any other item data, will search the cart stored in the session provider for matching products in the cart, returning the item key
and quantity
in the cart. With this knowledge, you can render your single product's cart option according to what actions are available to the end-user in relation to that product.
Here is the code for useCartMutation.js
:
import { useEffect, useState } from 'react'; import { useSession } from './UserSessionProvider.jsx'; import { AddToCart, UpdateCartItemQuantities, RemoveItemsFromCart, } from './graphql'; const useCartMutations = ( productId, variationId, extraData, ) => { const { cart, setCart, findInCart, } = useSession(); const [quantityFound, setQuantityInCart] = useState( findInCart(productId, variationId, extraData)?.quantity as number || 0, ); const [addToCart, { loading: adding }] = useMutation({ mutation: AddToCart, onCompleted({ addToCart: data }) { if (data?.cart) { setCart(data.cart); } }, }); const [updateQuantity, { loading: updating }] = useMutation({ mutation: UpdateCartItemQuantities, onCompleted({ updateItemQuantities: data }) { if (data?.cart) { setCart(data.cart as Cart); } }, }); const [removeCartItem, { loading: removing }] = useMutation({ mutation: RemoveItemsFromCart, onCompleted({ removeItemsFromCart: data }) { if (data?.cart) { setCart(data.cart as Cart); } }, }); useEffect(() => { setQuantityInCart( findInCart(productId, variationId, extraData)?.quantity || 0, ); }, [productId, variationId, extraData, cart?.contents?.nodes]); const mutate = async (values) => { const { quantity, all = false, mutation = 'update', } = values; if (!cart) { return; } if (!productId) { throw new Error('No item provided.'); // TODO: Send error to Sentry.IO. } switch (mutation) { case 'remove': { if (!quantityFound) { throw new Error('Provided item not in cart'); } const item = findInCart( productId, variationId, extraData, ); if (!item) { throw new Error('Failed to find item in cart.'); } const { key } = item; removeCartItem({ variables: { keys: [key], all } }); break; } case 'update': default: if (quantityFound) { const item = findInCart( productId, variationId, extraData, ); if (!item) { throw new Error('Failed to find item in cart.'); } const { key } = item; updateQuantity({ variables: { items: [{ key, quantity }] } }); } else { addToCart({ variables: { input: { productId, variationId, quantity, extraData, }, }, }); } break; } }; return { quantityInCart: quantityFound, mutate, loading: adding || updating || removing, }; }; export default useCartMutations;
With the useCartMutations
hook implemented, you can use it within your components to handle cart actions for a given product, variation, or any other item data. The hook returns the quantity of the item currently in the cart, a mutate
function that can be used to add, update, or remove items, and a loading
flag indicating whether any cart mutations are in progress.
You can now use this hook to create and manage cart interactions in your components. For instance, you can create an "Add to Cart" button that adds items to the cart, updates the quantity of an existing item, or removes an item from the cart.
Here's an example of how you could use the useCartMutations hook within a React component use our SingleProduct component from the previous guide:
import React, { useEffect, useState } from 'react'; import { useQuery } from '@apollo/client'; import { GetProduct } from './graphql'; const SingleProduct = ({ productId }) => { const [quantity, setQuantity] = useState(1); const { data, loading, error } = useQuery(GetProduct, { variables: { id: productId, idType: 'DATABASE_ID' }, }); const { quantityInCart: inCart, mutate, loading } = useCartMutations(productId); useEffect(() => { if (inCart) { setQuantity(inCart); } }, [inCart]) if (loading) return <p>Loading...</p>; if (error) return <p>Error: {error.message}</p>; const handleAddOrUpdateAction = async () => { mutate({ quantity }); } const handleRemoveAction = async () => { mutate({ mutation: 'remove', quantity: 0 }); } const buttonText = inCart ? 'Update' : 'Add To Cart'; return ( <div className="single-product"> {/* Rest of component */} <div className="cart-options"> {!product.soldIndividually && ( <div className="quantity"> <label htmlFor="quantity">Quantity:</label> <input type="number" id="quantity" name="quantity" min="1" defaultValue={inCart ? inCart : 1} onChange={(event) => setQuantity(Number.parseInt(event.target.value)))} /> </div> )} {product.stockStatus === 'IN_STOCK' ? ( <> <button type="button" className="add-to-cart" onClick={handleAddOrUpdateAction} disabled={loading} > {buttonText} </button> {inCart && ( <button type="button" className="remove-from-cart" onClick={handleRemoveAction} disabled={loading} > Remove </button> )} </> ) : ( <p>Out of stock</p> )} </div> </div> ); }; export default SingleProduct;
In this example, we have our SingleProduct
component that receives a productId
. It uses the useCartMutations
hook to manage the cart actions. The component renders the product information and provides buttons to add, update, or remove the item from the cart.
The handleAddOrUpdateAction
and handleRemoveAction
functions call the mutate
function returned by the useCartMutations
. The loading
flag is used to disable the buttons while any cart mutations are in progress.
This is just an example of how you could use the useCartMutations
hook and only using simple products, but as I'm sure you noticed it support a variationId
as the second parameter. Implementing Variable product support in our SingleProduct
component is out of the scope this guide, but with what has been provided you should have no problem implementing variable product support.
In conclusion, we've created a custom React hook, useCartMutations
, which allows you to manage cart actions like adding, updating, and removing items in an e-commerce application. We've used the Apollo Client's useMutation hook to interact with the GraphQL API and manage the state of the cart. Then, we've demonstrated how to use the custom useCartMutations
hook within our SingleProduct
component to perform cart-related actions.
This custom hook can help you create a more organized and modular e-commerce application by abstracting the cart logic and keeping your components clean and focused. You can further modify and extend the useCartMutations
hook and the SingleProduct
component to suit the specific requirements of your application.
By leveraging the power of custom hooks and GraphQL in your React application, you can create a robust and efficient e-commerce solution that scales well and provides a great user experience.