Building a Custom Data Bridge Utility
Create a unified, type-safe utility that combines all data patterns into a single API for accessing Shopify data in React components.
We’ve covered three patterns for passing data from Liquid to React. Now let’s build a unified utility that provides a clean, type-safe API for all of them. This data bridge will be the foundation for all our React components.
The Goal
Instead of scattered data access:
// Scattered access - hard to maintain and inconsistent.const productEl = document.getElementById('product-data');const product = JSON.parse(productEl?.textContent || '{}');const cartCount = window.Shopify?.cart?.itemCount ?? 0;const sectionId = root?.dataset.sectionId;// Each data access uses a different pattern!We want a unified API:
// Clean, unified access - one import, consistent API.const product = bridge.getProduct();const cart = bridge.getCart();const settings = bridge.getSectionSettings('product-main');// All data access through a single, type-safe interface.The Data Bridge Module
Let’s build this step by step:
/** * Shopify Data Bridge * * A unified API for accessing Shopify data from React components. * Handles data attributes, JSON script tags, and window globals. */
// ============================================// Types// ============================================
export interface ShopData { name: string; url: string; currency: string; moneyFormat: string; locale: string;}
export interface CustomerData { id: number; email: string; firstName: string; lastName: string; name: string; ordersCount: number; tags: string[];}
export interface CartSummary { itemCount: number; totalPrice: number; currency: string;}
export interface ProductVariant { id: number; title: string; price: number; compareAtPrice: number | null; available: boolean; sku: string; options: string[];}
export interface ProductImage { id: number; src: string; alt: string; width: number; height: number;}
export interface ProductOption { name: string; position: number; values: string[];}
export interface ProductData { id: number; title: string; handle: string; available: boolean; description: string; vendor: string; type: string; tags: string[]; price: number; compareAtPrice: number | null; images: ProductImage[]; variants: ProductVariant[]; options: ProductOption[]; selectedVariantId: number;}
export interface CartItem { key: string; productId: number; variantId: number; title: string; variantTitle: string; quantity: number; price: number; linePrice: number; image: string; url: string; handle: string;}
export interface CartData { token: string; itemCount: number; totalPrice: number; totalDiscount: number; note: string | null; items: CartItem[];}
// ============================================// JSON Script Tag Reader// ============================================
/** * Reads and parses JSON from a script tag * Returns the parsed data or null if not found/invalid */function readJsonScript(elementId: string): unknown { const element = document.getElementById(elementId);
if (!element?.textContent) { return null; }
try { return JSON.parse(element.textContent); } catch (error) { console.error(`[DataBridge] Failed to parse JSON from #${elementId}:`, error); return null; }}
// ============================================// Data Attribute Reader// ============================================
/** * How to parse each attribute: as a string, number, or boolean */type AttributeType = 'string' | 'number' | 'boolean';
/** * A map of attribute names to their types * Example: { productId: 'string', quantity: 'number' } */type AttributeSchema = Record<string, AttributeType>;
/** * Reads data attributes from an element and parses them */function readDataAttributes( elementId: string, schema: AttributeSchema): Record<string, unknown> | null { const element = document.getElementById(elementId); if (!element) return null;
const result: Record<string, unknown> = {};
for (const [key, type] of Object.entries(schema)) { const value = element.dataset[key];
if (value === undefined) continue;
switch (type) { case 'number': result[key] = parseFloat(value); break; case 'boolean': result[key] = value === 'true'; break; default: result[key] = value; } }
return result;}
// ============================================// Global Data Accessors// ============================================
function getShopData(): ShopData { return { name: window.Shopify?.shop?.name ?? '', url: window.Shopify?.shop?.url ?? '', currency: window.Shopify?.shop?.currency ?? 'USD', moneyFormat: window.Shopify?.shop?.moneyFormat ?? '${{amount}}', locale: window.Shopify?.locale?.code ?? 'en', };}
function getCustomer(): CustomerData | null { const customer = window.Shopify?.customer; if (!customer) return null;
return { id: customer.id, email: customer.email, firstName: customer.firstName, lastName: customer.lastName, name: customer.name, ordersCount: customer.ordersCount, tags: customer.tags, };}
function getCartSummary(): CartSummary { return { itemCount: window.Shopify?.cart?.itemCount ?? 0, totalPrice: window.Shopify?.cart?.totalPrice ?? 0, currency: window.Shopify?.cart?.currency ?? 'USD', };}
function getRoutes() { return { cart: window.Shopify?.routes?.cartUrl ?? '/cart', cartAdd: window.Shopify?.routes?.cartAddUrl ?? '/cart/add.js', cartChange: window.Shopify?.routes?.cartChangeUrl ?? '/cart/change.js', cartUpdate: window.Shopify?.routes?.cartUpdateUrl ?? '/cart/update.js', search: window.Shopify?.routes?.searchUrl ?? '/search', predictiveSearch: window.Shopify?.routes?.predictiveSearchUrl ?? '/search/suggest', };}
function getPageContext() { return { template: window.Shopify?.page?.template ?? '', suffix: window.Shopify?.page?.suffix ?? '', };}
// ============================================// Content Data Accessors// ============================================
/** * Reads product data from a JSON script tag */function getProduct(elementId = 'product-data'): ProductData | null { return readJsonScript(elementId) as ProductData | null;}
/** * Reads cart data from a JSON script tag */function getCart(elementId = 'cart-data'): CartData | null { return readJsonScript(elementId) as CartData | null;}
/** * Reads collection data from a JSON script tag */function getCollection(elementId = 'collection-data'): unknown { return readJsonScript(elementId);}
/** * Reads product recommendations from a JSON script tag */function getRecommendations(elementId = 'recommendations-data'): ProductData[] { const data = readJsonScript(elementId); return (data as ProductData[]) ?? [];}
// ============================================// Section Settings// ============================================
/** * Section settings can contain strings, numbers, booleans, or null */type SectionSettings = Record<string, string | number | boolean | null>;
/** * Reads section settings from a JSON script tag */function getSectionSettings(sectionId: string): SectionSettings | null { return readJsonScript(`section-settings-${sectionId}`) as SectionSettings | null;}
/** * Reads mount point data attributes for a component */function getMountData(rootId: string, schema: AttributeSchema): Record<string, unknown> | null { return readDataAttributes(rootId, schema);}
// ============================================// Money Formatting// ============================================
function formatMoney(cents: number): string { const format = getShopData().moneyFormat; const amount = (cents / 100).toFixed(2);
return format .replace('{{amount}}', amount) .replace('{{amount_no_decimals}}', Math.round(cents / 100).toString()) .replace('{{amount_with_comma_separator}}', amount.replace('.', ','));}
// ============================================// Export the Bridge// ============================================
export const bridge = { // Global data getShop: getShopData, getCustomer, getCartSummary, getRoutes, getPage: getPageContext,
// Content data getProduct, getCart, getCollection, getRecommendations,
// Section/component data getSectionSettings, getMountData,
// Low-level access readJson: readJsonScript, readAttributes: readDataAttributes,
// Utilities formatMoney,
// Convenience checks isLoggedIn: () => !!getCustomer(), isTemplate: (name: string) => getPageContext().template === name,};
// Default export for convenienceexport default bridge;Using the Data Bridge
In Components
import { useState, useEffect } from 'react';import { bridge, ProductData } from '@/utils/data-bridge';
export function ProductForm() { const [product, setProduct] = useState<ProductData | null>(null);
// Read product data on mount using the bridge. useEffect(() => { const data = bridge.getProduct(); // Type-safe access. setProduct(data); }, []);
if (!product) return null;
return ( <form className="product-form"> <h1>{product.title}</h1> {/* Use bridge's formatMoney for consistent currency formatting */} <p className="price">{bridge.formatMoney(product.price)}</p> <VariantSelector options={product.options} variants={product.variants} /> </form> );}With Custom Mount Data
import { useEffect, useState } from 'react';import { bridge } from '@/utils/data-bridge';
export function QuantitySelector({ rootId }: { rootId: string }) { const [mountData, setMountData] = useState<Record<string, unknown> | null>(null);
useEffect(() => { // Read data attributes with type conversion via bridge. const data = bridge.getMountData(rootId, { lineKey: 'string', quantity: 'number', // Will be parsed as number, not string. maxQuantity: 'number', }); setMountData(data); }, [rootId]);
if (!mountData) return null;
// Type assertions - values are already correctly typed from bridge. const lineKey = mountData.lineKey as string; const quantity = mountData.quantity as number; const maxQuantity = mountData.maxQuantity as number;
return <div className="quantity-selector">{/* Use quantity, maxQuantity, etc. */}</div>;}For Global UI
import { bridge } from '@/utils/data-bridge';
export function Header() { // Access all global data through the bridge. const shop = bridge.getShop(); const cart = bridge.getCartSummary(); const customer = bridge.getCustomer();
return ( <header> <a href="/" className="logo"> {shop.name} </a>
<nav> {/* Use bridge.getRoutes() for API URLs */} <a href={bridge.getRoutes().cart}>Cart ({cart.itemCount})</a>
{/* Customer-aware UI */} {customer ? ( <span>Welcome, {customer.firstName}</span> ) : ( <a href="/account/login">Log In</a> )} </nav> </header> );}React Hooks Layer
For a more React-idiomatic API, wrap the bridge in hooks:
import { useState, useEffect, useMemo } from 'react';import { bridge, ProductData, CartData, CustomerData } from '@/utils/data-bridge';
// ============================================// Global Data Hooks (no state needed)// ============================================
export function useShop() { return useMemo(() => bridge.getShop(), []);}
export function useCustomer(): CustomerData | null { return useMemo(() => bridge.getCustomer(), []);}
export function useIsLoggedIn(): boolean { return useMemo(() => bridge.isLoggedIn(), []);}
export function useCartSummary() { return useMemo(() => bridge.getCartSummary(), []);}
export function useRoutes() { return useMemo(() => bridge.getRoutes(), []);}
// ============================================// Content Data Hooks (with loading state)// ============================================
export function useProduct(elementId = 'product-data') { const [product, setProduct] = useState<ProductData | null>(null); const [isLoading, setIsLoading] = useState(true);
useEffect(() => { const data = bridge.getProduct(elementId); setProduct(data); setIsLoading(false); }, [elementId]);
return { product, isLoading };}
export function useCart(elementId = 'cart-data') { const [cart, setCart] = useState<CartData | null>(null); const [isLoading, setIsLoading] = useState(true);
useEffect(() => { const data = bridge.getCart(elementId); setCart(data); setIsLoading(false); }, [elementId]);
return { cart, isLoading };}
/** * Hook for reading section settings from a JSON script tag */export function useSectionSettings(sectionId: string) { const [settings, setSettings] = useState<Record<string, unknown> | null>(null);
useEffect(() => { const data = bridge.getSectionSettings(sectionId); setSettings(data); }, [sectionId]);
return settings;}
// ============================================// Money Formatting Hook// ============================================
export function useFormatMoney() { return bridge.formatMoney;}Usage with hooks:
function ProductPage() { // Use React hooks wrapper around the bridge. const { product, isLoading } = useProduct(); const formatMoney = useFormatMoney(); const customer = useCustomer();
// Handle loading and error states. if (isLoading) return <Skeleton />; if (!product) return <NotFound />;
return ( <div> <h1>{product.title}</h1> <p>{formatMoney(product.price)}</p> {/* Only show wishlist for logged-in customers */} {customer && <WishlistButton productId={product.id} />} </div> );}Liquid Setup for the Bridge
Ensure your Liquid templates provide the data the bridge expects:
{%- comment -%} layout/theme.liquid - Global data {%- endcomment -%}<script> window.Shopify = { shop: { name: {{ shop.name | json }}, url: {{ shop.url | json }}, currency: {{ shop.currency | json }}, moneyFormat: {{ shop.money_format | json }} }, locale: { code: {{ request.locale.iso_code | json }}, rootUrl: {{ request.locale.root_url | json }} }, customer: {% if customer %}{ id: {{ customer.id }}, email: {{ customer.email | json }}, firstName: {{ customer.first_name | json }}, lastName: {{ customer.last_name | json }}, name: {{ customer.name | json }}, ordersCount: {{ customer.orders_count }}, tags: {{ customer.tags | json }} }{% else %}null{% endif %}, cart: { itemCount: {{ cart.item_count }}, totalPrice: {{ cart.total_price }}, currency: {{ cart.currency.iso_code | json }} }, page: { template: {{ template.name | json }}, suffix: {{ template.suffix | default: '' | json }} }, routes: { cartUrl: {{ routes.cart_url | json }}, cartAddUrl: {{ routes.cart_add_url | json }}, cartChangeUrl: {{ routes.cart_change_url | json }}, cartUpdateUrl: {{ routes.cart_update_url | json }}, searchUrl: {{ routes.search_url | json }}, predictiveSearchUrl: {{ routes.predictive_search_url | json }} } };</script>{%- comment -%} sections/product-main.liquid - Product data {%- endcomment -%}<script type="application/json" id="product-data"> { "id": {{ product.id }}, "title": {{ product.title | json }}, {%- comment -%} ... full product object ... {%- endcomment -%} }</script>Testing the Bridge
Create simple tests for your bridge:
describe('DataBridge', () => { beforeEach(() => { // Mock window.Shopify window.Shopify = { shop: { name: 'Test Shop', currency: 'USD', moneyFormat: '${{amount}}' }, customer: null, cart: { itemCount: 3, totalPrice: 5000 }, }; });
test('getShop returns shop data', () => { expect(bridge.getShop().name).toBe('Test Shop'); });
test('isLoggedIn returns false when no customer', () => { expect(bridge.isLoggedIn()).toBe(false); });
test('formatMoney formats correctly', () => { expect(bridge.formatMoney(1999)).toBe('$19.99'); });});Key Takeaways
- Unified API — One import gives access to all Shopify data
- Type safety — TypeScript interfaces for all data structures
- Separation of concerns — Bridge handles data, components handle UI
- Flexible access — Use bridge directly or via React hooks
- Testable — Easy to mock for unit testing
- Consistent patterns — Same approach for all data types
The data bridge is now complete! In the next module, we’ll build the React foundation and component architecture for your Shopify theme.
Finished this lesson?
Mark it complete to track your progress.
Discussion
Loading comments...