Skip to content

Instantly share code, notes, and snippets.

@blittle
Last active June 26, 2023 15:39
Show Gist options
  • Save blittle/d9205d4ac72528005dc6f3104c328ecd to your computer and use it in GitHub Desktop.
Save blittle/d9205d4ac72528005dc6f3104c328ecd to your computer and use it in GitHub Desktop.
Product Form Proposal

Product Form

E-commerce sites usually have a form on the product detail page where options are submitted to the server when the user adds to the cart.

Variant Selection

The most basic product form provides the ability to select from available variants. For example, variants might include product size or color:

Basic Product Selection Form

This basic use-case comes with a few challenges:

  1. Variant Availability - Sometimes each variant option is only selectable if the product is available.
  2. Default Selection - When the page is first loaded, some merchants want the first available variant to be selected by default. Other merchants force the user to make a variant selection before the add to cart button is enabled.

While it may be tempting to build this variant selection with radio buttons, we feel it is a better user experience to use links. Selecting an option with the underlying HTML element as an <a href=""> has the following benefits:

  1. The page is immediately interactive when visible. On a slow connection, you don't need to wait for the page to finish loading to select a variant.
  2. The URL is automatically updated and unique per variation. Search engines can index each variation separately.
  3. The user can share and bookmark the exact selected variation with a friend via the URL.
  4. The variation can be prefetched on hover, increasing the percieved load time.
  5. The user can right click and open each variation in a new window or tab.

<VariationSelector />

We make it easier to render product variations with the <VariationSelector /> component:

import {VariantSelector} from '@shopify/hydrogen';

const ProductForm = ({product}) => {
  return (
    <VariantSelector options={product.options} variants={product.variants}>
      {({option}) => (
        <>
          <div>{option.name}</div>
          <div>
            {option.values.map(({value, isAvailable, path, isActive}) => (
              <Link
                to={path}
                prefetch="intent"
                className={
                  isActive ? 'active' : isAvailable ? '' : 'opacity-80'
                }
              >
                {value}
              </Link>
            ))}
          </div>
        </>
      )}
    </VariantSelector>
  );
};

The VariantSelector component calculates availability by joining the options and variants props. But if your product has many variants, it may not be feasible to query all variants. If an associated variant is not found for a one of the options, it is assumed to be available.

Default variant

Sometimes the product form should have a default variant selected, so that the user can immediately add to cart without selecting a variant. You can do this by passing defaultVariant to <VariantSelector>. We also provide a helper getFirstAvailableVariant to make it easier:

import {VariantSelector, getFirstAvailableVariant} from '@shopify/hydrogen';

const ProductForm = ({product}) => {
  const defaultVariant = getFirstAvailableVariant(product);

  return (
    <VariantSelector
      options={product.options}
      variants={product.variants}
      defaultVariant={defaultVariant}
    >
      {({option}) => (
        <>
          <div>{option.name}</div>
          <div>
            {option.values.map(({value, isAvailable, path, isActive}) => (
              <Link
                to={path}
                prefetch="intent"
                className={
                  isActive ? 'active' : isAvailable ? '' : 'opacity-80'
                }
              >
                {value}
              </Link>
            ))}
          </div>
        </>
      )}
    </VariantSelector>
  );
};

getSelectedProductOptions()

Within your loaders, we provide a utility to parse selected options from the request URL search params:

import {getSelectedProductOptions} from '@shopify/hydrogen';

export async function loader({request, params}) {
  const selectedOptions = getSelectedProductOptions(request);

  const {product} = await context.storefront.query(PRODUCT_QUERY, {
    variables: {
      handle: params.productHandle,
      selectedOptions,
    },
  });

  return json({product});
}

Now that we pass the selectedOptions into the PRODUCT_QUERY, we can update that query to use variantBySelectedOptions to query the selected variant:

const PRODUCT_QUERY = `#graphql
  query Product(
    $handle: String!
    $selectedOptions: [SelectedOptionInput!]!
  ) {
    product(handle: $handle) {
      id
      title
      vendor
      handle
      descriptionHtml
      description
      options {
        name
        values
      }
      selectedVariant: variantBySelectedOptions(selectedOptions: $selectedOptions) {
        ...ProductVariantFragment
      }
      media(first: 7) {
        nodes {
          ...Media
        }
      }
      variants(first: 10) {
        nodes {
          ...ProductVariantFragment
        }
      }
      seo {
        description
        title
      }
    }
  }

Add to Cart

Use the CartForm component with the product.selectedVariant to create an Add to Cart button. Usually the button should also be disabled if the selectedVariant is invalid or unavailable.

import {
  VariantSelector,
  getFirstAvailableVariant,
  CartForm,
} from '@shopify/hydrogen';

const ProductForm = ({product}) => {
  const defaultVariant = getFirstAvailableVariant(product);

  return (
    <>
      <VariantSelector
        options={product.options}
        variants={product.variants}
        defaultVariant={defaultVariant}
      >
        {({option}) => (
          <>
            <div>{option.name}</div>
            <div>
              {option.values.map(({value, isAvailable, path, isActive}) => (
                <Link
                  to={path}
                  prefetch="intent"
                  className={
                    isActive ? 'active' : isAvailable ? '' : 'opacity-80'
                  }
                >
                  {value}
                </Link>
              ))}
            </div>
          </>
        )}
      </VariantSelector>
      <CartForm
        route="/cart"
        action={CartForm.ACTIONS.LinesAdd}
        lines={[
          {
            merchandiseId: product.selectedVariant?.id,
          },
        ]}
      >
        <button
          disabled={
            !product.selectedVariant?.id ||
            !product.selectedVariant?.availableForSale
          }
        >
          Add to Cart
        </button>
      </CartForm>
    </>
  );
};

Quantity

Some product properties should not be apart of the URL. These should be added to your CartForm as normal form inputs:

import {VariantSelector, CartForm} from '@shopify/hydrogen';

const ProductForm = ({product}) => {
  const [quantity, setQuantity] = useState(1);
  return (
    <>
      <VariantSelector options={product.options} variants={product.variants}>
        {({option}) => (
          <>
            <div>{option.name}</div>
            <div>
              {option.variants.map(({value, isAvailable, path}) => (
                <Link
                  to={path}
                  prefetch="intent"
                  className={isAvailable ? '' : 'opacity-80'}
                >
                  {value}
                </Link>
              ))}
            </div>
          </>
        )}
      </VariantSelector>
      <label>
        Quantity:
        <input
          type="number"
          min="1"
          value={quantity}
          onChange={(e) => setQuantity(e.target.value)}
        />
      </label>
      <CartForm
        route="/cart"
        action={CartForm.ACTIONS.LinesAdd}
        lines={[
          {
            merchandiseId: product.selectedVariant.id,
            quantity,
          },
        ]}
      >
        <button>Add to Cart</button>
      </CartForm>
    </>
  );
};
@benjaminsehl
Copy link

benjaminsehl commented Jun 22, 2023

For quantity, semantically I think the input should be inside CartForm as well as any line item properties.

So …

<CartForm action={CartForm.ACTIONS.LinesAdd}>

    <input type="hidden" name="variant" value={product.selectedVariant.id} />

    <label>
      Quantity:
      <input type="number" name="quantity" pattern="[0-9]*" value="1" min="1"/>
    </label>
      
    <fieldset>
      <legend for="gift_note">Gift Note:</legend>
      <input id="gift_note" type="text" name="attributes[Gift Note]" />
    </fieldset>
      
    <input
      type="hidden"
      value={hiddenAttributeValue}
      name="attributes[_hidden-attribute]"
    />
      
    <button 
      type="submit"
      disabled={
        !product.selectedVariant?.id ||
        !product.selectedVariant?.availableForSale
      }
    >
      Add to Cart
    </button>

</CartForm>

Curious to get thoughts on this!

@frandiox
Copy link

If the quantity is an input inside the Form, do we still need useState? We could read it from request.formData, right?

And another question... do we lose the quantity input state when clicking on other variants since they are links? Would this be a problem?

@blittle
Copy link
Author

blittle commented Jun 23, 2023

@frandiox @benjaminsehl I misunderstood the Cart proposal. It wasn't clear to me from the docs that a simple input field could be used. I thought you had to use react state for the lines prop.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment