Introduction and Architecture Overview Intermediate 12 min read

Architecture Deep Dive: Liquid Data Layer, React UI Layer

Understand the hybrid architecture pattern where Liquid serves as the data layer and React handles the UI. Learn how data flows between the two systems and how they work together.

The secret to a successful React-powered Shopify theme is understanding the separation of concerns: Liquid handles data, React handles UI. In this lesson, we’ll explore this architecture in depth and see how the two systems communicate.

The Rendering Timeline

When a customer visits your Shopify store, here’s what happens:

1. Browser requests /products/cool-shirt
2. Shopify server processes request
3. Liquid templates render with product data
4. HTML sent to browser (contains React mount points + serialized data)
5. Browser parses HTML, shows initial content (good for SEO)
6. React JavaScript loads and executes
7. React hydrates/mounts on designated elements
8. Page becomes fully interactive

This is fundamentally different from a Single Page Application (SPA) where React renders everything. Our hybrid approach gives us the best of both worlds.

Liquid as the Data Layer

Liquid has exclusive access to Shopify’s data objects. It knows about:

  • Products, variants, and inventory
  • Collections and their products
  • Cart contents and totals
  • Customer information
  • Shop settings and metafields
  • Navigation menus

React, running in the browser, has no direct access to this data. We need Liquid to serialize and pass this data to React.

The Data Handoff Pattern

Here’s the core pattern we’ll use throughout the course:

{%- comment -%} sections/product-main.liquid {%- endcomment -%}
{%- comment -%} 1. Liquid renders the SEO-critical content {%- endcomment -%}
<section class="product-main">
<h1 class="product-title">{{ product.title }}</h1>
<div class="product-description">{{ product.description }}</div>
{%- comment -%} 2. Create a mount point for React {%- endcomment -%}
<div
id="product-form-root"
class="product-form"
data-product-id="{{ product.id }}"
>
{%- comment -%} 3. Fallback content for no-JS or loading {%- endcomment -%}
<noscript>
{%- form 'product', product -%}
<button type="submit">Add to Cart</button>
{%- endform -%}
</noscript>
</div>
{%- comment -%} 4. Serialize complex data as JSON {%- endcomment -%}
<script type="application/json" id="product-data">
{
"id": {{ product.id }},
"title": {{ product.title | json }},
"handle": {{ product.handle | json }},
"available": {{ product.available }},
"variants": [
{%- for variant in product.variants -%}
{
"id": {{ variant.id }},
"title": {{ variant.title | json }},
"price": {{ variant.price }},
"available": {{ variant.available }},
"options": {{ variant.options | json }}
}{% unless forloop.last %},{% endunless %}
{%- endfor -%}
],
"options": {{ product.options_with_values | json }}
}
</script>
</section>

Why This Pattern Works

  1. SEO First: The <h1> and description render in the initial HTML, visible to search engines
  2. Progressive Enhancement: The noscript fallback ensures basic functionality without JavaScript
  3. Clean Separation: React components don’t need to know about Liquid syntax
  4. Type Safety: JSON data can be validated with TypeScript interfaces

React as the UI Layer

React’s job is to read the serialized data and render interactive components:

src/components/ProductForm.tsx
import { useEffect, useState } from 'react';
import { useCart } from '../stores/cart';
// TypeScript interfaces for type safety.
interface Variant {
id: number;
title: string;
price: number;
available: boolean;
options: string[];
}
interface ProductData {
id: number;
title: string;
handle: string;
available: boolean;
variants: Variant[];
options: { name: string; values: string[] }[];
}
export function ProductForm() {
const [product, setProduct] = useState<ProductData | null>(null);
const [selectedVariant, setSelectedVariant] = useState<Variant | null>(null);
const { addItem, isLoading } = useCart(); // Get cart actions from store.
// Read data serialized by Liquid on component mount.
useEffect(() => {
const dataElement = document.getElementById('product-data');
if (dataElement) {
const data = JSON.parse(dataElement.textContent || '{}');
setProduct(data);
setSelectedVariant(data.variants[0]); // Default to first variant.
}
}, []);
// Don't render until data is loaded.
if (!product) return null;
const handleAddToCart = async () => {
if (!selectedVariant) return;
await addItem(selectedVariant.id, 1);
};
return (
<div className="product-form-react">
{/* VariantSelector is a child component handling option selection */}
<VariantSelector
options={product.options}
variants={product.variants}
selected={selectedVariant}
onChange={setSelectedVariant}
/>
{/* Button disabled when variant unavailable or currently adding */}
<button onClick={handleAddToCart} disabled={!selectedVariant?.available || isLoading}>
{isLoading ? 'Adding...' : 'Add to Cart'}
</button>
</div>
);
}

The Mount Strategy

React needs to know where to render. We use a mounting system that finds designated elements:

src/main.tsx
import { createRoot } from 'react-dom/client';
import { ProductForm } from './components/ProductForm';
import { CartDrawer } from './components/CartDrawer';
import { Header } from './components/Header';
// Registry mapping DOM element IDs to React components.
// Only elements that exist will be mounted.
const MOUNT_POINTS = {
'product-form-root': ProductForm,
'cart-drawer-root': CartDrawer,
'header-root': Header,
} as const;
// Mount React components to their designated Liquid-rendered elements.
function mountComponents() {
Object.entries(MOUNT_POINTS).forEach(([elementId, Component]) => {
const element = document.getElementById(elementId);
if (element) {
// Only mount if the element exists on this page.
const root = createRoot(element);
root.render(<Component />);
}
});
}
// Ensure DOM is ready before mounting.
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', mountComponents);
} else {
mountComponents(); // DOM already ready.
}

This approach is flexible—React only mounts where needed, and pages without specific components skip them automatically.

Data Flow Architecture

Here’s the complete data flow in our hybrid theme:

┌───────────────────────────────────────────────────────────┐
│ SHOPIFY SERVER │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Liquid Templates │ │
│ │ • Access to all Shopify objects │ │
│ │ • Renders initial HTML │ │
│ │ • Serializes data for React │ │
│ └─────────────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────┘
│ HTML + JSON data
┌───────────────────────────────────────────────────────────┐
│ BROWSER │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Initial HTML │ │
│ │ • SEO content visible │ │
│ │ • Mount points defined │ │
│ │ • JSON data in script tags │ │
│ └─────────────────────────────────────────────────────┘ │
│ │ │
│ │ React reads data │
│ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ React Components │ │
│ │ • Parse JSON data │ │
│ │ • Manage UI state │ │
│ │ • Handle user interactions │ │
│ └─────────────────────────────────────────────────────┘ │
│ │ │
│ │ API calls │
│ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Shopify AJAX API │ │
│ │ • Cart operations │ │
│ │ • Predictive search │ │
│ │ • Product recommendations │ │
│ └─────────────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────┘

State Management Strategy

With React handling the UI, we need a strategy for managing state across components:

Local State

For component-specific state that doesn’t need to be shared:

// Local state - only this component needs these values.
const [isOpen, setIsOpen] = useState(false);
const [quantity, setQuantity] = useState(1);

Global State

For state that multiple components need to access:

// Global state with Zustand - shared across multiple components.
// Header, CartDrawer, and ProductForm all access this store.
const useCart = create((set) => ({
items: [],
isOpen: false,
addItem: async (variantId, quantity) => {
/* API call to add item */
},
removeItem: async (lineId) => {
/* API call to remove item */
},
toggleDrawer: () => set((state) => ({ isOpen: !state.isOpen })),
}));

Server State

For data that comes from the server and may need revalidation:

// Server state - data that comes from API and may need revalidation.
// Use React Query, SWR, or similar for caching and refetching.
const { data: recommendations } = useQuery({
queryKey: ['recommendations', productId],
queryFn: () => fetchRecommendations(productId),
});

File Structure Overview

Here’s how our theme files will be organized:

theme/
├── assets/
│ ├── react-bundle.js # Built React app
│ └── react-bundle.css # Built styles
├── config/
├── layout/
│ └── theme.liquid # Loads React bundle
├── sections/
│ ├── header.liquid # Contains #header-root
│ ├── product-main.liquid # Contains #product-form-root
│ └── cart-drawer.liquid # Contains #cart-drawer-root
├── snippets/
├── templates/
└── src/ # React source (not deployed)
├── components/
│ ├── Header.tsx
│ ├── ProductForm.tsx
│ └── CartDrawer.tsx
├── stores/
│ └── cart.ts
├── hooks/
├── types/
│ └── shopify.ts
├── utils/
│ └── data-bridge.ts
└── main.tsx

The src/ directory is where you write React code. The build process compiles it into assets/ files that Shopify serves.

Communication Patterns

1. Liquid → React (Initial Load)

Data embedded in HTML, read once when React mounts:

<script type="application/json" id="shop-data">
{
"currency": {{ shop.currency | json }},
"moneyFormat": {{ shop.money_format | json }}
}
</script>

2. React → Shopify (User Actions)

API calls from React components:

// Add to cart - React calls Shopify's AJAX API.
await fetch('/cart/add.js', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: variantId, quantity }),
});

3. React ↔ React (Component Communication)

Through global state stores:

// In ProductForm - trigger state change.
const { addItem } = useCart();
await addItem(variantId, 1);
// In Header - automatically re-renders when store changes.
const { itemCount } = useCart();
return <span className="cart-count">{itemCount}</span>;
// Both components subscribe to the same Zustand store.

Key Principles

As we build out this architecture, keep these principles in mind:

  1. Liquid owns the data: Never try to fetch product/collection data client-side when Liquid can provide it
  2. React owns the interactions: Complex UI state belongs in React, not data attributes
  3. Progressive enhancement: The page should work (basically) without JavaScript
  4. Type everything: Use TypeScript interfaces for all Liquid → React data
  5. Minimize bridge complexity: Pass only the data React actually needs

Key Takeaways

  1. Liquid renders first with SEO content and serialized data
  2. React mounts second on designated elements, reading the serialized data
  3. State is managed locally, globally, or as server state depending on scope
  4. APIs handle mutations (cart operations) while Liquid provides read data
  5. The build process compiles React into theme assets

In the next lesson, we’ll compare the tools and libraries you can use for this architecture and help you choose your stack.

Finished this lesson?

Mark it complete to track your progress.

Discussion

Loading comments...