Imported from H-ymt/hydrogen-store-demo (
.agents/skills/hydrogen-variant-form/SKILL.md). Install upstream withnpx skills add H-ymt/hydrogen-store-demo --skill hydrogen-variant-form. Copyright stays with the author.
Product Form Primitive
The product form primitive is a client-side store that computes per-option-value existence, availability, and selection state from Shopify's encoded variant fields. Variant selection performs no network requests; it is derived from product data provided by the Storefront API, combined with live cart state for error surfacing and line-item matching. Form submission is delegated to the cart store. Framework-specific bindings are thin wrappers over the core createProductFormStore.
How the store works
The store holds a ProductFormStoreState and notifies subscribers on change. It is initialized with a product object and a CartStore instance. The product includes encodedVariantExistence, encodedVariantAvailability, product options (each with firstSelectableVariant per option value), a sparse variant cache (adjacentVariants), and a nullable selectedOrFirstAvailableVariant.
On initialization:
- The store uses the encoded fields, when present, to determine which option-value combinations exist and which are available (in stock). Matching is symmetric: every option value is evaluated against all selected options that belong to the current product option matrix, regardless of option order.
- It stitches together the sparse variant cache (
adjacentVariants+firstSelectableVariantper option value +selectedOrFirstAvailableVariant) to resolve a concretevariantobject per option value where possible. - It sets the initial selection from
selectedOrFirstAvailableVariant(server-resolved), falling back to explicitly providedselectedOptions, falling back to empty selection. - It subscribes to the
CartStoreto derivematchedLineItemanderrorsreactively.
On selectOption(name, value):
- The store validates that the option name and value exist on the product.
- It validates that the combination exists when Shopify provides an encoded existence field.
- It updates
selectedOptionsand recomputes the full options grid. - It returns a
VariantSelectionResult:resolved(full variant matched),unresolved(valid, but no full variant resolved locally), orinvalid(unknown or non-existent, with areasonstring).
The store does not own URL synchronization or navigation. Add-to-cart mutation logic belongs to the cart store and is reached through handleFormSubmit(event).
State shape
interface ProductFormStoreState<TVariant> {
options: VariantOptionState<TVariant>[];
selectedOptions: SelectedOption[];
selectedVariant: TVariant | null;
errors: ProductFormErrors;
matchedLineItem: CartLine | null;
}
options is the computed grid — one entry per product option, each containing its values:
value.name— the option value label (e.g. "Red", "Small").value.selected— whether this value is part of the current selection.value.exists— resolved fromencodedVariantExistencewhen present.falsemeans no variant exists for the current symmetric selection constraints — the control should be disabled and visually de-emphasized.value.available— resolved fromencodedVariantAvailabilitywhen present.falsemeans no available variant was found for the current symmetric selection constraints.value.variant— the resolved variant object, ornullif the combination is not in the local cache.value.selectedOptions— the full option tuple that would result from selecting this value. Used for URL construction.value.handle— the product handle for this variant. Differs from the current product's handle in combined listings.
selectedOptions is the current selection in product-option order (e.g. [{name: "Size", value: "Small"}, {name: "Color", value: "Red"}]).
selectedVariant is the currently resolved variant, or null when the selection is incomplete or the variant is not in the local cache.
errors surfaces cart errors relevant to this product form:
userErrors— merged cart-level and line-level user errors for the matched line item.warnings— merged cart-level and line-level warnings.networkErrors— cart network errors.
matchedLineItem is the cart line whose merchandise.id matches the selected variant's ID, or null.
Selection results
selectOption returns one of three outcomes:
resolved— a specific variant was matched. ContainsselectedVariantwith the full variant object andselectedOptionswith the canonical option tuple from the variant.unresolved— the selection is valid, but no full variant is resolved locally because the selection is incomplete or the exact variant is absent from the bounded cache. If the buyer has not selected every option yet, ask for the remaining options. If the selection is complete but missing from the local cache, fetch or otherwise resolve the exact variant before treating it as complete.invalid— the option name or value does not exist on the product, or the combination does not exist per the encoded existence field. State is not updated. Contains areasonstring explaining why the selection was rejected.
Register API
The register function binds product form identity and activation handlers. It covers product-relevant fields. It intentionally does not emit option control UI props like checked, disabled, or aria-pressed; derive those from options, selectedVariant, and caller-owned state.
register("merchandiseId", opts)
Returns { name: "merchandiseId", value: selectedVariantId }. The value is an empty string when no variant is resolved.
register("quantity", opts)
Accepts { value: number } for a controlled input or { defaultValue: number } for an uncontrolled input. Returns the appropriate { name, value } or { name, defaultValue } props, with numeric values stringified for HTML form submission.
register("optionValue", opts)
Requires { optionName, value }. Returns { name, value, onChange, onClick } — form identity plus activation handlers. Derive caller-owned UI props from the matching option value state, such as value.exists, value.available, and value.selected.
register("attributeValue", opts)
Requires { key: string; value: string } (controlled) or { key: string; defaultValue: string } (uncontrolled). Returns { name: "attributes.<key>", value } — a hidden input that attaches a line-item attribute to the add-to-cart submission. Use for per-line metadata like engraving text, gift messages, or custom options. Multiple attributeValue fields can be registered for different keys. The key must be non-empty (throws TypeError otherwise). Attributes with keys starting with _ are conventionally internal/private and should not be set from the storefront UI.
register("addToCart", opts)
Returns { name: "add-to-cart", type: "submit" } for the add-to-cart submit button.
Form submission
handleFormSubmit(event) delegates to the underlying CartStore's form submission. The store does not own submission logic — it passes the SubmitEvent through to the cart layer, plus selected-product event detail when a variant is resolved. Cart errors from the submission are surfaced reactively via the errors state.
Route Placement
When creating a product detail page, use the app's existing route convention when present; otherwise create /products/{handle}. Keep variant selection in query params on that product route.
Framework References
Before building product UI, check whether this skill has a reference file for the app's framework in references/. If one exists, read it and use that framework binding or route pattern first.
If there is no matching reference, use createProductFormStore from @shopify/hydrogen directly, subscribe with the framework's reactivity primitive, and own hydration, URL sync, and destroy() yourself. Packaged bindings are thin wrappers over this same store — apply every rule and anti-pattern below; do not invent a different contract.
Hydration
When the product data changes (e.g. after a URL navigation triggers a data refetch), the store must be hydrated with the new product — not recreated. hydrate(product, opts?) replaces the product data, clears the decoded variant cache, and recomputes state. The new product's selectedOrFirstAvailableVariant takes priority; if absent, falls back to explicitly provided opts.selectedOptions, then to the selection that was active before hydration.
Provider bindings should hydrate automatically when the product's semantic identity changes (product.id + selectedOrFirstAvailableVariant.id). They should skip hydration on mount to avoid double-initialization, and skip hydration when the product identity is unchanged — preserving user selections through unrelated re-renders. Standalone useProductForm(store) users manage hydration themselves.
Reset
reset() restores the store to its initial state — the product and selected options it was created with. It clears the decoded variant cache and recomputes all derived state. Useful for "reset form" interactions.
Extracting selected options from the URL
getSelectedProductOptions({ searchParams, allowedOptionNames }) extracts selected options from URLSearchParams. Each query parameter is treated as an option name/value pair (e.g. ?Color=Red&Size=M produces [{name:"Color",value:"Red"},{name:"Size",value:"M"}]).
Pass allowedOptionNames to filter search params to only known product option names, avoiding unrelated query parameters. Passing an empty array filters out every option.
The variant param is reserved for Liquid-style numeric variant ids and is never treated as an option name. Server-side, handleShopifyRoutes({ routeTemplates }) (see the local hydrogen-request-handlers skill) redirects ?variant=<id> product URLs to their canonical option-params URL before the loader runs; when both variant and option params are present, the variant wins.
Building selection URLs
buildProductSelectionSearchParams({ style?, selectedOptions, variant?, optionNames, base? }) builds the search params for a selection link. It always removes the reserved variant param and every param named in optionNames/selectedOptions from base before writing the new selection, preserving unrelated params (?ref=campaign). Compose it with the app's product pathname:
const params = buildProductSelectionSearchParams({
selectedOptions: result.selectedOptions,
optionNames: product.options.map((option) => option.name),
base: new URLSearchParams(location.search),
});
const url = `/products/${handle}${params.size ? `?${params}` : ""}`;
Pass style: "variant" with a resolved variant to emit a shareable ?variant=<numeric id> link instead of option params. When no variant is resolved (partial selection), the variant style falls back to option params — a variant link is not constructible. Prefer option-params links for in-app navigation; ?variant= links cost a server redirect on landing.
Rules
- Hide the entire variant picker when no option has more than one value (
options.every((o) => o.values.length <= 1)). A single-variant product has nothing to choose — rendering its one option/one value is noise. This is distinct from the value-hiding rule below: it hides the whole picker when there's nothing to choose, not individual values based on selection.
Existence and availability
- Availability and existence are symmetric. Selecting a later option can make earlier option values unavailable or non-existent. For example, if
Medium / Oliveis sold out, selectingColor=Oliveshould markSize=Mediumas unavailable even thoughMedium / Greenis available. - ALWAYS disable and visually de-emphasize option values where
existsisfalse. These represent combinations that do not exist in the product's variant matrix. A non-existent combination cannot be selected — showing it as interactive is misleading. - NEVER disable option values where
existsistruebutavailableisfalse. These are sold-out variants. The control must remain interactive so the buyer can see what the variant would be. Show a "Sold out" indicator instead, such as opacity plus line-through styling. - NEVER hide option values based on the current selection. All values for an option must always be visible. Hiding values based on what's currently selected creates a confusing, collapsing UI that prevents buyers from exploring the full product matrix.
Selection and navigation
- With provider bindings, put URL navigation in the provider
onSelectcallback. Do not navigate inside each same-product option button. Letregister("optionValue", ...)callselectOption; the provider receives the valid selection result and owns URL sync from there. - ALWAYS use URL-based variant selection in URL-routing apps. When the buyer selects an option, navigate to the URL representing that selection — replacing the history entry and without resetting scroll position. The URL is the durable source of truth — the data loader reads the selection from the URL, queries the Storefront API, and the response hydrates the store when a reload or revalidation happens.
- Same-product option values must degrade to GET links (no JS). In URL-routing apps, render each same-product option value as a real link (the framework's link component or
<a href>) whosehrefis the option URL for that value, built fromvalue.selectedOptions— not a bare<button onClick>. Thehrefis the no-JS path: with scripting off, activating it issues a GET to the option URL, the loader reads the params, queries the Storefront API, and the server renders the newly selected variant. Hydration enhances the same element — the registeredregister("optionValue", ...)handler callsselectOptionand the provider'sonSelectreplaces the URL client-side (no full reload). Because thehrefand the client navigation resolve to the same option URL, behavior is identical with or without JS. On a hydrated click the element both runs the registered handler (which callsselectOption) and performs its own link navigation to that same URL, so keep the provideronSelectidempotent — the redundant navigation is a harmless no-op. An option control that is button-/onClick-only renders nothing a no-JS shopper can act on, stranding them on the default variant. Non-existent combinations (exists: false) have no valid option URL to degrade to; render those as a disabled<button>, not a link. (Cross-product values are already links; this brings same-product values to parity.) - ALWAYS use the
selectOptionreturn value for navigation, not a reactive effect on state.selectOptionreturns the result synchronously. Use the returnedselectedOptionsto construct the next URL immediately. Reacting to derived state (e.g.selectedOptionsfrom the store) instead introduces an unnecessary update cycle and risks stale values. Framework bindings may wrap this as anonSelectcallback — the principle is the same. - Skip data refetch only when the resolved local state is sufficient. When
selectOptionreturnsresolved, the selected variant is already in the local cache. It is safe to skip revalidation only if the route does not need fresh loader data for other UI. When a complete selection returnsunresolved, fetch or otherwise resolve the exact variant before treating the selection as complete. - NEVER call
selectOptionfor combined-listing cross-product values. Whenvalue.handle !== product.handle, the value belongs to a different product. Render it as a navigation element (anchor or link component) that navigates to the other product's URL — not a button that callsselectOption. The store only knows about the current product's variant matrix.
Combined listings
- ALWAYS check
value.handleagainst the currentproduct.handle. If they differ, the option value points to a different product in a combined listing. The UI must navigate to that product (full page navigation or a link component), not callselectOption. - Use the framework's client-side link component for cross-product values when one exists. Use the app's idiomatic navigation primitive. Use a raw
<a>only when that is the app's established routing convention. - Preserve non-option query params on combined-listing links. When constructing the URL for a combined-listing link, carry forward existing search params (e.g.
?ref=campaign) and replace only the option params. UsebuildProductSelectionSearchParamswith the current product's option names asoptionNames— it deletes all option params (and any stalevariantparam) first, then sets the new ones, preventing stale params when combined-listing products have different option names. - Ignore selected options that are not in the current product option matrix. Divergent combined-listing child products can have different option names. A stale
Color=Blackparam must not constrain a child product whose options areSizeandMount.
Price display
- ALWAYS display server-provided prices. Use
selectedVariant.pricewhen a variant is resolved. Fall back toproduct.priceRange.minVariantPricewhen no variant is selected. Never compute prices client-side. - Format with Hydrogen money helpers, not string concatenation. Use the local
hydrogen-moneyskill for app wrappers aroundformatMoney().
Add-to-cart
- ALWAYS use
canAddToCart(product, options)to determine if the add-to-cart button should be enabled. This checks three conditions: a variant is selected, it is available for sale, and the product does not require a selling plan. Checking onlyselectedVariant !== nullmisses the selling-plan and availability constraints. - The add-to-cart form is separate from the variant selector. Variant selection uses buttons and links — not form submissions. The add-to-cart form contains
merchandiseId(the selected variant ID),quantity, and optionallyattributeValuefields for line-item attributes. Do not put variant selection controls inside the cart form. - Use
registerto bind form fields.register("merchandiseId", {})returns the hidden input props with the current variant ID.register("quantity", { value: 1 })returns the quantity input props.register("attributeValue", { key: "Engraving", value })returns a hidden input for a line-item attribute.register("addToCart", {})returns stable add-to-cart submit button props. These stay synchronized with store state automatically. - Use the local
hydrogen-shop-payskill when adding accelerated checkout near the add-to-cart form. - Show contextual CTA text. When
canAddToCartistrue: "Add to cart". When no variant is selected (selectedVariant === null): "Select options" unless a navigation or submission is actually pending. When a variant is selected but unavailable: "Unavailable" or "Sold out". - Surface cart errors from
errorsstate. After form submission, user errors, warnings, and network errors relevant to the current product form are available onstate.errors. Display these to the buyer.
Store lifecycle
- Create the store once per component mount. Do not recreate the store when the product prop changes — use
hydrate()instead. Recreating the store discards the decoded variant cache and any user interaction state. - Always call
destroy()on unmount. This unsubscribes from theCartStoreand releases internal caches (the decoded variant field cache). Provider bindings handle this automatically; standalone store users must do it themselves. - Do not read stale state after hydration. Hydration triggers a synchronous state update. Any code that caches the previous state reference before hydration holds a stale reference.
Accessibility
- Communicate selected state with the attribute that fits the element. Same-product and cross-product option values render as links (per the GET-links and combined-listing rules), so mark the selected one with
aria-current—aria-pressedis not a valid state on a link. Reservearia-pressed={value.selected}for values rendered as a<button>, chiefly the disabled non-existent (exists: false) case. Derive either from the matching option value'sselectedstate. - Use
aria-labelon visual-only controls (e.g. color swatches without visible text). - Disabled controls (
exists: false) must use the nativedisabledattribute — notaria-disabledwith prevented clicks. Non-existent combinations are truly non-interactive.
User Acceptance Tests
Initial state
- Pre-selected variant — When the product has a
selectedOrFirstAvailableVariant, the corresponding option values are marked as selected on first render. The variant's price and details are displayed.selectedVarianton the state is non-null. - No pre-selected variant — When
selectedOrFirstAvailableVariantisnull, no option values are selected.selectedVariantisnull. The add-to-cart button shows "Select options" (or equivalent) and is disabled. - URL-driven selection — When the URL encodes a variant selection (e.g. via option params like
?Color=Blue&Size=Small), the data loader resolves the selection and passes it to the Storefront API query. The returnedselectedOrFirstAvailableVariantreflects the URL selection, and the store initializes with those options selected.
Option selection
- Select a value — Click an option value button. The value becomes selected (visually indicated). If the selection resolves to a variant, the price and variant details update immediately.
selectedVariantupdates on the state. In URL-routing apps, the URL updates to reflect the new selection without a scroll reset. - Multi-option selection — On a product with Size and Color options, select Size=Large then Color=Blue. Both selections are reflected in the state. The resolved variant matches Large/Blue.
- Symmetric availability — On a product where Medium/Olive is unavailable but Medium/Green is available, selecting Color=Olive marks Size=Medium as
available: false; selecting Color=Green marks Size=Medium asavailable: true. - Switch within an option — With Color=Red selected, click Color=Blue. The selection switches; Red is deselected, Blue is selected. Only one value per option is selected at a time.
- Invalid selection ignored — Calling
selectOptionwith an unknown option name or value returnsinvalidwith areasonstring and does not change state. No navigation occurs. - Non-existent combination — An option value where
exists: falseis disabled. Clicking it does nothing. - Sold-out variant — An option value where
exists: trueandavailable: falseis interactive but shows a "Sold out" indicator. Selecting it updates the state and shows the variant as unavailable.
Combined listings
- Cross-product value — An option value where
value.handle !== product.handlerenders as a navigation element (anchor or link component), not a button. Clicking it navigates to the other product's page with the appropriate option params. - Same-product value — An option value where
value.handle === product.handlerenders as a GET link to its option URL and spreadsregister("optionValue", ...). With JavaScript disabled the link navigates and the server resolves the variant; hydrated, the registered handler callsselectOptionand the provider syncs the URL client-side. - Preserved params — When navigating via a combined-listing link, non-option query params from the current URL are preserved in the destination URL.
Hydration
- Product navigation — Navigating from Product A to Product B (different
product.id) hydrates the store with Product B's data. The selection reflects Product B'sselectedOrFirstAvailableVariant. - Same product, different variant — Navigating to the same product with a different URL-encoded selection (e.g.
?Color=Blueinstead of?Color=Red) hydrates with the new pre-selected variant without recreating the store. - Unrelated re-render — A re-render that passes the same product identity does not hydrate. User selections made since the last hydration are preserved.
- No double-init on mount — On initial mount, the store initializes from the constructor — hydration does not fire. A user selection made immediately after mount survives a subsequent re-render with the same product.
Cart integration
- Matched line item — When the selected variant's ID matches a cart line's
merchandise.id,matchedLineItemis non-null and contains the cart line data. - Cart error surfacing — After a failed add-to-cart submission,
errors.userErrorscontains the relevant user errors,errors.warningscontains warnings, anderrors.networkErrorscontains any network failures. - Reactive cart sync — When the cart updates externally (e.g. quantity change from a cart drawer), the
matchedLineItemanderrorsupdate without any manual intervention.
Add-to-cart
- Enabled state — When
canAddToCartreturnstrue(variant selected, available, no selling plan required), the add-to-cart button is enabled and shows "Add to cart". - Disabled — no variant — When no variant is selected, the button is disabled with "Select options" text unless navigation or submission is actually pending.
- Disabled — sold out — When the selected variant is not available for sale, the button is disabled with "Unavailable" or "Sold out" text.
- Disabled — selling plan required — When
product.requiresSellingPlanistrue, the button is disabled regardless of variant selection. - Variant ID in form —
register("merchandiseId", {})returns{ name: "merchandiseId", value: selectedVariantId }. When no variant is selected,valueis an empty string. - Form submission —
handleFormSubmit(event)delegates to the cart store with the submit event and selected-product detail when a variant is resolved. Cart errors surface reactively via theerrorsstate.
Register API
- Option value registration —
register("optionValue", { optionName: "Color", value: "Red" })returns{ name, value, onChange, onClick }. CallingonChangeoronClicktriggersselectOption. - Caller-owned option attributes —
register("optionValue", { optionName: "Color", value: "Red" })returns only{ name, value, onChange, onClick }. Derivedisabled,aria-pressed, and visual state from the matchingoptionsvalue. - Quantity registration —
register("quantity", { value: 1 })returns{ name: "quantity", value: "1" }.register("quantity", { defaultValue: 1 })returns{ name: "quantity", defaultValue: "1" }. - Add-to-cart registration —
register("addToCart", {})returns{ name: "add-to-cart", type: "submit" }. - Attribute value registration —
register("attributeValue", { key: "Engraving", value: "Hello" })returns{ name: "attributes.Engraving", value: "Hello" }.register("attributeValue", { key: "Engraving", defaultValue: "" })returns{ name: "attributes.Engraving", defaultValue: "" }. An emptykeythrows aTypeError. - Attribute in add-to-cart — When
register("attributeValue", { key: "Engraving", value: "Hello" })is included as a hidden input in the add-to-cart form, the submitted cart line carries the attribute. The attribute appears on the matched line item in the cart drawer (with_-prefixed internal attributes filtered out).
Unresolved selection
- Transient unresolved — In URL-routing apps, when a complete selection returns
unresolvedbecause the exact variant is absent from the local cache, the app navigates to the new URL and re-fetches product data. The subsequent hydration resolves the variant. Incomplete selections should remain in selection UI until the buyer chooses the remaining options.
Reset
- Reset to initial state — Calling
reset()restores the store to the product and selected options it was created with. All user selections are discarded.
Anti-patterns
- Using reactive effects on state to sync selection to URL. The
selectOptionreturn value provides the selection result synchronously. Reacting tostate.selectedOptionsinstead introduces an extra update cycle and can fire with stale values. - Navigating inside same-product option buttons. Do not destructure
onClick/onChangefromregister("optionValue", ...), callnavigate(), and then call the registered handler manually. This bypasses the provideronSelectcontract and can navigate from stale or invalid data. - Button-/onClick-only same-product option values. A same-product option value rendered as
<button onClick={selectOption}>with nohrefis dead without JavaScript — a no-JS shopper cannot switch variants and is stuck on the default. Render it as a GET link to the option URL and let the registered handler enhance it; thehrefis the progressive-enhancement fallback. - Using raw anchors when the framework has a client-side link component. Raw
<a>tags lose client-router behavior such as scroll preservation, pending navigation state, prefetching, and route transitions. Use the app's established link component unless raw anchors are the framework convention. - Putting variant selection inside the add-to-cart form. Variant selection is button/link interactions that update store state. The add-to-cart form submits
merchandiseIdandquantityto the cart. Mixing them creates ambiguous form semantics and breaks progressive enhancement. - Ignoring
errorsstate after form submission. Cart user errors, warnings, and network errors are surfaced reactively on the store state. Failing to display these leaves the buyer with no feedback when something goes wrong.