Passing Data from Liquid to React: Patterns Overview
Learn the different patterns for passing Shopify data from Liquid templates to React components. Understand when to use each approach.
React components running in the browser have no direct access to Shopify’s data—products, collections, cart contents, and shop settings all live on the server. Liquid is our bridge. In this module, we’ll explore the patterns for passing data from Liquid to React.
The Core Challenge
Liquid templates execute on Shopify’s servers. React runs in the browser. They never execute at the same time:
SERVER (Liquid) BROWSER (React)───────────────── ─────────────────1. Request comes in2. Liquid accesses Shopify data3. Liquid renders HTML4. HTML sent to browser ─────────▶ 5. Browser receives HTML 6. JavaScript loads 7. React mounts 8. React needs data... 🤔By the time React runs, Liquid is long finished. So how does React get the data it needs?
Answer: Liquid embeds the data in the HTML before sending it to the browser. React then reads this embedded data.
Three Patterns for Data Transfer
We’ll cover three patterns, each suited for different scenarios:
| Pattern | Best For | Complexity |
|---|---|---|
| Data Attributes | Simple values (IDs, flags, single strings) | Low |
| JSON Script Tags | Complex objects (products, cart, settings) | Medium |
| Global Window Objects | Shared configuration, multi-component data | Medium |
Pattern 1: Data Attributes
The simplest approach—embed data directly on the mount point element:
{%- comment -%} Liquid template {%- endcomment -%}<div id="product-form-root" data-product-id="{{ product.id }}" data-available="{{ product.available }}" data-handle="{{ product.handle }}"></div>// React componentfunction ProductForm() { // Get the mount point element. const root = document.getElementById('product-form-root');
// Read data attributes from the element. // Note: dataset values are always strings from the DOM. const productId = root?.dataset.productId; const available = root?.dataset.available === 'true'; // Parse string to boolean. const handle = root?.dataset.handle;
// Use the data...}When to use: Simple values like IDs, boolean flags, or short strings.
Limitations: Not suitable for complex objects, arrays, or large data sets.
Pattern 2: JSON Script Tags
Embed complex data as JSON in a non-executing script tag:
{%- comment -%} Liquid template {%- endcomment -%}<div id="product-form-root"></div>
<script type="application/json" id="product-data"> { "id": {{ product.id }}, "title": {{ product.title | json }}, "available": {{ product.available }}, "variants": {{ product.variants | json }}, "options": {{ product.options_with_values | json }} }</script>// React componentfunction ProductForm() { // Find the JSON script tag by its ID. const dataEl = document.getElementById('product-data'); // Parse the JSON content. Provide empty object as fallback. const product = JSON.parse(dataEl?.textContent || '{}');
console.log(product.title); // "Cool T-Shirt" console.log(product.variants); // [{id: 123, ...}, ...] - Full objects, not strings!}When to use: Products, collections, cart data, or any complex objects.
Key detail: type="application/json" prevents the browser from executing the script.
Pattern 3: Global Window Objects
Set data on the global window object for access anywhere:
{%- comment -%} In theme.liquid or a snippet {%- endcomment -%}<script> window.Shopify = window.Shopify || {}; window.Shopify.shop = { name: {{ shop.name | json }}, currency: {{ shop.currency | json }}, moneyFormat: {{ shop.money_format | json }}, locale: {{ shop.locale | json }} }; window.Shopify.customer = {% if customer %}{{ customer | json }}{% else %}null{% endif %}; window.Shopify.cart = {{ cart | json }};</script>// Anywhere in your React app - global data is always accessible.const shopName = window.Shopify?.shop?.name; // Optional chaining for safety.const isLoggedIn = !!window.Shopify?.customer; // Double-bang converts to boolean.const cartCount = window.Shopify?.cart?.item_count || 0; // Default to 0 if undefined.When to use: Data needed by multiple components, or app-wide configuration.
Tradeoff: Pollutes global scope, but very convenient for shared data.
Choosing the Right Pattern
Here’s a decision guide:
Is the data a simple value (ID, flag, string)? └─ Yes → Use DATA ATTRIBUTES └─ No ↓
Is the data needed by only one component? └─ Yes → Use JSON SCRIPT TAG └─ No ↓
Is the data needed by multiple components or globally? └─ Yes → Use GLOBAL WINDOW OBJECTReal-World Example: Product Page
A typical product page might use all three patterns:
{%- comment -%} shopify-theme/sections/product-main.liquid {%- endcomment -%}
{%- comment -%} Pattern 1: Simple mount point with basic data {%- endcomment -%}<div id="product-form-root" data-section-id="{{ section.id }}"></div>
{%- comment -%} Pattern 2: Complex product data {%- endcomment -%}<script type="application/json" id="product-json"> { "id": {{ product.id }}, "title": {{ product.title | json }}, "handle": {{ product.handle | json }}, "available": {{ product.available }}, "price": {{ product.price }}, "compareAtPrice": {{ product.compare_at_price | default: 'null' }}, "images": [ {%- for image in product.images -%} { "id": {{ image.id }}, "src": {{ image.src | image_url: width: 1000 | json }}, "alt": {{ image.alt | json }} }{% unless forloop.last %},{% endunless %} {%- endfor -%} ], "variants": {{ product.variants | json }}, "options": {{ product.options_with_values | json }} }</script>
{%- comment -%} Pattern 3: Global shop/customer data (usually in theme.liquid) {%- endcomment -%}Type Safety with TypeScript
Each pattern can be typed for safety:
// For data attributes - values are ALWAYS strings from the DOM.interface ProductMountData { productId: string; available: string; // "true" or "false", not boolean. handle: string;}
// For JSON script tags - types are preserved from JSON.interface ProductData { id: number; // Actual number, not string. title: string; handle: string; available: boolean; // Actual boolean. price: number; compareAtPrice: number | null; images: ProductImage[]; variants: ProductVariant[]; options: ProductOption[];}
// For window object - extend the global Window interface.declare global { interface Window { Shopify?: { shop?: ShopData; customer?: CustomerData | null; // null when logged out. cart?: CartData; }; }}SEO Considerations
An important benefit of all these patterns: Liquid renders SEO content before React loads:
<section class="product-main"> {%- comment -%} This content is visible to search engines {%- endcomment -%} <h1>{{ product.title }}</h1> <p class="price">{{ product.price | money }}</p> <div class="description">{{ product.description }}</div>
{%- comment -%} React enhances this area with interactivity {%- endcomment -%} <div id="product-form-root"></div>
<script type="application/json" id="product-json"> {{ product | json }} </script></section>Search engines see the full content. React adds interactivity on top.
Common Pitfalls
1. Forgetting to Escape JSON
Always use the json filter for strings:
{%- comment -%} Wrong - breaks if title has quotes {%- endcomment -%}"title": "{{ product.title }}"
{%- comment -%} Correct {%- endcomment -%}"title": {{ product.title | json }}2. Nil Values in JSON
Handle nil values explicitly:
{%- comment -%} Wrong - outputs empty string, invalid JSON {%- endcomment -%}"compareAtPrice": {{ product.compare_at_price }}
{%- comment -%} Correct {%- endcomment -%}"compareAtPrice": {{ product.compare_at_price | default: 'null' }}3. Reading Data Before It Exists
Ensure the DOM is ready:
// Wrong - might run before DOM is ready (at module load time).const data = document.getElementById('product-json');
// Correct - useEffect runs after component mounts, DOM is guaranteed ready.useEffect(() => { const data = document.getElementById('product-json'); // Now safe to access DOM elements.}, []);What’s Coming Next
In the following lessons, we’ll dive deep into each pattern:
- Data Attributes — Simple component hydration
- JSON Script Tags — Complex data serialization
- Global Window Objects — Shared state initialization
- Custom Data Bridge — A utility that unifies all patterns
Key Takeaways
- Liquid runs on the server, React in the browser—they never overlap
- Three patterns for data transfer: attributes, JSON scripts, window objects
- Choose based on complexity: simple values → attributes, complex → JSON, shared → window
- Always use the
jsonfilter when embedding Liquid variables in JSON - SEO content renders in Liquid—React enhances with interactivity
In the next lesson, we’ll master the data attributes pattern for simple component hydration.
Finished this lesson?
Mark it complete to track your progress.
Discussion
Loading comments...