Skip to main content

Defer child product loading in combined listings

Use product.options_with_values to render combined listing options without loading child product data, and load a child product only when a buyer selects an option value, using the Section Rendering API.


A combined listing is a parent product that links several child products together so that they're merchandised as one item. Each child product is a separate product with its own variants, images, and URL, so rendering all of them up front multiplies backend queries. A parent with 20 to 60 children can add seconds to TTFB, and including descriptions and full image sets for each child makes it worse.


Anchor to Decide whether this applies to youDecide whether this applies to you

Combined listings are available on Shopify Plus plans only, and they're supported on the online store only. If your store isn't on Shopify Plus, then you don't need this pattern.


Render option values with product.options_with_values and the product_option_value objects returned by product_option.values. These give you availability and selection state without loading child products.

To switch between sibling products, use product_option_value.product_url. It returns the URL of the product that's associated with an option value, and it's the only Liquid mechanism for combined listing navigation.

Info

product_option_value.product_url returns nil unless the current product is a combined listing parent, and it also returns nil for option values that the child product owns. On a parent page, the parent's own options (such as color) return sibling product URLs, and options that come from a child (such as size) return nil. Treat a present product_url as the signal to navigate, and fall back to the regular option value selection flow when it's empty.

There's no Liquid property that reports whether a product is a combined listing parent or child. Branch on product_url instead.


What combined listings are:

  • Parent product: Can't be purchased, has no inventory, and is used for display.
  • Child products: Purchasable products with their own inventory, images, and URLs.
  • Relationship: Parent option values map to child products.
  • Example: "Shoes" (parent) with children "Shoes - Red", "Shoes - Blue", and "Shoes - Black".
  • Limits: Up to 2,000 variants across all child products, up to 60 child products per parent, and the parent can have three options.

Anchor to Problematic pattern: iterating all variantsProblematic pattern: iterating all variants

{%- for variant in product.variants -%}
{{ variant.title }}
{{ variant.image }}
{{ variant.price | money }}
{%- endfor -%}

This loads every variant that the parent exposes, which is exactly the data you're trying to defer. It's also capped at 250 variants, so it silently renders an incomplete picker for large listings.

<div class="option-value-selectors" data-section-id="{{ section.id }}">
{%- for option in product.options_with_values -%}
<fieldset>
<legend>{{ option.name }}</legend>

{%- for option_value in option.values -%}
<input
id="{{ section.id }}-{{ option.position }}-{{ forloop.index0 }}"
type="radio"
name="{{ section.id }}-{{ option.name }}-{{ option.position }}"
value="{{ option_value | escape }}"
{% if option_value.selected %}checked{% endif %}
{% unless option_value.available %}disabled{% endunless %}
data-option-value-id="{{ option_value.id }}"
data-product-url="{{ option_value.product_url }}"
onchange="onOptionValueChange(event)"
/>
<label for="{{ section.id }}-{{ option.position }}-{{ forloop.index0 }}">
{{ option_value }}
</label>
{%- endfor -%}
</fieldset>
{%- endfor -%}
</div>

Option values that belong to a child product render an empty data-product-url, which is the signal to re-render in place instead of navigating to a sibling product.

Anchor to Defer child product loading with the Section Rendering APIDefer child product loading with the Section Rendering API

<div class="product-info" id="product-info-{{ section.id }}" data-section-id="{{ section.id }}">
<h1>{{ product.title }}</h1>
<div class="price">{{ product.price | money }}</div>
</div>

<script>
function onOptionValueChange(event) {
const selectors = event.target.closest('.option-value-selectors');
const sectionId = selectors.dataset.sectionId;
const productUrl = event.target.dataset.productUrl;

const selectedOptionValues = Array.from(
selectors.querySelectorAll('input[type="radio"]:checked')
).map(({dataset}) => dataset.optionValueId);

const params = selectedOptionValues.length > 0
? `&option_values=${selectedOptionValues.join(',')}`
: '';

// An empty product URL means the option value belongs to the current
// product, so re-render the current page instead of a sibling product.
const basePath = productUrl || window.location.pathname;

fetch(`${basePath}?section_id=${sectionId}${params}`)
.then((response) => response.text())
.then((responseText) => {
const html = new DOMParser().parseFromString(responseText, 'text/html');

// Scope the update to the component that emitted the event. A bare
// `#product-info` lookup always returns the first match in the document,
// so a second product component on the page updates the wrong one.
document.getElementById(`product-info-${sectionId}`).innerHTML =
html.querySelector('.product-info').innerHTML;
selectors.innerHTML = html.querySelector('.option-value-selectors').innerHTML;
});
}
</script>

You can pass the selected option value IDs in any order, and you can pass a partial set. Values that you don't provide fall back to the first available value for that option.


  • Theme Inspector: Check for child product queries in nested loops.
  • TTFB comparison: Test a parent product with 20 or more child products.
  • Section rendering: Verify that sibling products load correctly through Ajax, and that option values without a product_url still update availability.


Was this page helpful?