Component Architecture for Shopify Themes
Design a component architecture that works within Shopify's constraints. Learn patterns for organizing components, handling props, and maintaining clean boundaries.
Building React components for a Shopify theme requires thinking differently than a standard React app. You’re not building a SPA—you’re enhancing a server-rendered page with islands of interactivity. Let’s design an architecture that fits this model.
The Islands Architecture
Think of your React components as “islands” of interactivity in a sea of static HTML:
┌───────────────────────────────────────────────────────────────┐│ SERVER-RENDERED HTML ││ (from Liquid) ││ ││ ┌─────────────┐ ┌─────────────┐ ││ │ Header │ Static content... │ Search │ ││ │ (React) │ │ (React) │ ││ └─────────────┘ └─────────────┘ ││ ││ Static hero banner... ││ ││ ┌───────────────────────────────────────────────────────┐ ││ │ Product Form (React) │ ││ │ Variant selector, quantity, add to cart │ ││ └───────────────────────────────────────────────────────┘ ││ ││ Static product description... ││ ││ ┌───────────────────────────────────────────────────────┐ ││ │ Cart Drawer (React) │ ││ └───────────────────────────────────────────────────────┘ │└───────────────────────────────────────────────────────────────┘Each island is self-contained but can communicate through shared state.
Component Categories
Organize components into clear categories:
src/components/├── cart/ # Cart-related components│ ├── CartDrawer.tsx│ ├── CartItem.tsx│ ├── CartTotals.tsx│ └── index.ts├── product/ # Product page components│ ├── ProductForm.tsx│ ├── VariantSelector.tsx│ ├── MediaGallery.tsx│ └── index.ts├── header/ # Header/navigation│ ├── Header.tsx│ ├── Navigation.tsx│ ├── MobileMenu.tsx│ └── index.ts├── search/ # Search functionality│ ├── SearchModal.tsx│ ├── SearchResults.tsx│ └── index.ts└── ui/ # Shared UI primitives ├── Button.tsx ├── Modal.tsx ├── Drawer.tsx └── index.tsThe Three Layers
1. Entry Components (Mount Points)
These are the components that mount directly to DOM elements:
// Entry components mount to DOM elements.// They orchestrate feature-level logic and compose sub-components.
export function ProductForm() { const product = useProduct(); // Get data from bridge. const { addItem } = useCart(); // Get actions from store.
return ( <div className="product-form"> {/* Compose from focused feature components */} <VariantSelector product={product} /> <QuantityInput /> <AddToCartButton onAdd={addItem} /> </div> );}2. Feature Components
Handle specific features within an entry component:
// Feature components handle one piece of functionality.// They're composed by entry components, not mounted directly.
interface VariantSelectorProps { product: Product; onVariantChange?: (variant: ProductVariant) => void; // Optional callback.}
export function VariantSelector({ product, onVariantChange }: VariantSelectorProps) { // Local state for this feature only. const [selectedOptions, setSelectedOptions] = useState<Record<string, string>>({});
// Feature-specific logic here...
return ( <div className="variant-selector"> {/* Map over options, compose with OptionGroup components */} {product.options.map((option) => ( <OptionGroup key={option.name} option={option} selected={selectedOptions[option.name]} onChange={(value) => handleOptionChange(option.name, value)} /> ))} </div> );}3. UI Primitives
Reusable, stateless building blocks:
// UI primitives are simple, reusable, and have no business logic.// They're the building blocks for feature components.
interface ButtonProps { variant?: 'primary' | 'secondary' | 'outline'; size?: 'small' | 'medium' | 'large'; loading?: boolean; disabled?: boolean; children: React.ReactNode; onClick?: () => void;}
export function Button({ variant = 'primary', // Default values for optional props. size = 'medium', loading = false, disabled = false, children, onClick,}: ButtonProps) { return ( <button className={`button button--${variant} button--${size}`} disabled={disabled || loading} // Disable when loading too. onClick={onClick} > {/* Show spinner when loading, otherwise show children */} {loading ? <Spinner /> : children} </button> );}Props Design Patterns
1. Data Props vs UI Props
Separate what a component displays from how it displays it:
interface ProductCardProps { // Data props - what to display product: Product;
// UI props - how to display it showQuickAdd?: boolean; imageAspectRatio?: 'square' | 'portrait' | 'landscape'; hoverEffect?: 'zoom' | 'swap' | 'none';}2. Controlled vs Uncontrolled Components
For form-like components, support both patterns:
interface QuantityInputProps { // Uncontrolled mode: component manages its own state. defaultValue?: number;
// Controlled mode: parent manages state. value?: number; onChange?: (quantity: number) => void;
// Shared configuration. min?: number; max?: number;}
export function QuantityInput({ defaultValue = 1, value, onChange, min = 1, max = 99,}: QuantityInputProps) { // Determine if controlled based on whether value prop is provided. const isControlled = value !== undefined; const [internalValue, setInternalValue] = useState(defaultValue);
// Use controlled value if provided, otherwise internal state. const currentValue = isControlled ? value : internalValue;
const handleChange = (newValue: number) => { // Validate bounds. if (newValue < min || newValue > max) return;
// Only update internal state if uncontrolled. if (!isControlled) { setInternalValue(newValue); } // Always notify parent if callback provided. onChange?.(newValue); };
return ( <div className="quantity-input"> <button onClick={() => handleChange(currentValue - 1)}>−</button> <span>{currentValue}</span> <button onClick={() => handleChange(currentValue + 1)}>+</button> </div> );}3. Render Props for Flexibility
Let parents customize rendering:
interface OptionGroupProps { option: ProductOption; selected: string; onChange: (value: string) => void;
// Render prop allows custom rendering of each option. renderOption?: (value: string, isSelected: boolean) => React.ReactNode;}
export function OptionGroup({ option, selected, onChange, renderOption }: OptionGroupProps) { return ( <div className="option-group"> <label>{option.name}</label> <div className="option-values"> {option.values.map((value) => { const isSelected = value === selected;
return ( <button key={value} className={isSelected ? 'selected' : ''} onClick={() => onChange(value)} > {/* Use render prop if provided, otherwise default to value */} {renderOption ? renderOption(value, isSelected) : value} </button> ); })} </div> </div> );}Component Composition
Build complex UIs by composing simple components:
// Bad: One massive component with mixed concerns.function ProductForm() { // 500 lines of mixed concerns - hard to maintain!}
// Good: Composed from focused, single-responsibility components.function ProductForm() { return ( <div className="product-form"> <ProductHeader /> <ProductPrice /> <VariantSelector /> <QuantitySection /> <AddToCartSection /> <ProductMeta /> </div> );}
// Each section handles one concern and is easy to test/maintain.function VariantSelector() { // Get data and actions from hooks. const { product, selectedVariant, selectVariant } = useProduct();
return ( <div className="variant-selector"> {product.options.map((option) => ( <OptionGroup key={option.name} option={option} variants={product.variants} selected={selectedVariant} onSelect={selectVariant} /> ))} </div> );}State Boundaries
Decide where state lives based on who needs it:
// Local state: Only this component needs it.function Accordion() { const [isOpen, setIsOpen] = useState(false); // Keep state local. // ...}
// Lifted state: Multiple sibling components need it.function ProductForm() { // State lifted to common parent. const [selectedVariant, setSelectedVariant] = useState(null);
return ( <> {/* Pass state down as props to children */} <VariantSelector selected={selectedVariant} onSelect={setSelectedVariant} /> <AddToCart variant={selectedVariant} /> </> );}
// Global state: Unrelated components need it.// Use Zustand, Context, or similar for app-wide state.function CartIcon() { const itemCount = useCart((state) => state.itemCount); return <span>{itemCount}</span>;}
function CartDrawer() { const { items, removeItem } = useCart(); // Both components subscribe to the same global store.}Styling Components
Keep styles close to components:
src/components/cart/├── CartDrawer.tsx├── CartDrawer.module.css # Styles live next to component├── CartItem.tsx├── CartItem.module.css└── index.tsUsing CSS Modules:
import styles from './CartDrawer.module.css'; // Import scoped styles.
export function CartDrawer() { return ( // CSS Module classes are locally scoped - no conflicts. <div className={styles.drawer}> <header className={styles.header}> <h2>Your Cart</h2> <button className={styles.closeButton}>×</button> </header> {/* ... */} </div> );}/* These classes are automatically scoped to this component. */
.drawer { position: fixed; right: 0; top: 0; height: 100vh; width: 400px; background: white;}
.header { display: flex; justify-content: space-between; padding: 1rem; border-bottom: 1px solid #eee;}
.closeButton { background: none; border: none; font-size: 1.5rem; cursor: pointer;}Integration with Liquid
Components should work seamlessly with Liquid-rendered content:
{%- comment -%} sections/product-main.liquid {%- endcomment -%}
<section class="product-main"> {%- comment -%} Liquid renders SEO content {%- endcomment -%} <h1 class="product-title">{{ product.title }}</h1> <p class="product-vendor">{{ product.vendor }}</p>
{%- comment -%} React handles the interactive form {%- endcomment -%} <div id="product-form-root"></div>
{%- comment -%} Liquid renders static description {%- endcomment -%} <div class="product-description"> {{ product.description }} </div>
{%- comment -%} Data for React {%- endcomment -%} <script type="application/json" id="product-data"> {{ product | json }} </script></section>Component File Structure
A complete component file:
// 1. Importsimport { useEffect, useRef } from 'react';import { useCart } from '@/stores/cart';import { useUI } from '@/stores/ui';import { CartItem } from './CartItem';import { CartTotals } from './CartTotals';import { Button } from '@/components/ui/Button';import styles from './CartDrawer.module.css';
// 2. Typesinterface CartDrawerProps { className?: string;}
// 3. Componentexport function CartDrawer({ className }: CartDrawerProps) { // Hooks const { items, itemCount, isLoading } = useCart(); const { isCartOpen, closeCart } = useUI(); const drawerRef = useRef<HTMLDivElement>(null);
// Effects useEffect(() => { if (isCartOpen) { document.body.style.overflow = 'hidden'; } else { document.body.style.overflow = ''; }
return () => { document.body.style.overflow = ''; }; }, [isCartOpen]);
// Early returns if (!isCartOpen) return null;
// Render return ( <div className={styles.overlay} onClick={closeCart}> <div ref={drawerRef} className={`${styles.drawer} ${className || ''}`} onClick={(event) => event.stopPropagation()} > <header className={styles.header}> <h2>Cart ({itemCount})</h2> <button onClick={closeCart} aria-label="Close cart"> × </button> </header>
<div className={styles.content}> {items.length === 0 ? ( <EmptyCart onClose={closeCart} /> ) : ( <ul className={styles.items}> {items.map((item) => ( <CartItem key={item.key} item={item} /> ))} </ul> )} </div>
{items.length > 0 && ( <footer className={styles.footer}> <CartTotals /> <Button variant="primary" size="large"> Checkout </Button> </footer> )} </div> </div> );}
// 4. Sub-components (if small and only used here)function EmptyCart({ onClose }: { onClose: () => void }) { return ( <div className={styles.empty}> <p>Your cart is empty</p> <Button onClick={onClose}>Continue Shopping</Button> </div> );}Key Takeaways
- Islands architecture: React components are isolated interactive areas in server-rendered HTML
- Three layers: Entry components, feature components, and UI primitives
- Clear boundaries: Each component has one job and does it well
- Props design: Separate data props from UI props, support controlled and uncontrolled patterns
- Composition: Build complex UIs from simple, focused components
- State boundaries: Keep state as local as possible, lift only when needed
- Styles with components: CSS Modules keep styles scoped and maintainable
In the next lesson, we’ll define TypeScript types for all the Shopify objects your components will work with.
Finished this lesson?
Mark it complete to track your progress.
Discussion
Loading comments...