React Foundation in Theme Context Intermediate 12 min read

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.ts

The 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.ts

Using CSS Modules:

CartDrawer.tsx
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>
);
}
CartDrawer.module.css
/* 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:

src/components/cart/CartDrawer.tsx
// 1. Imports
import { 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. Types
interface CartDrawerProps {
className?: string;
}
// 3. Component
export 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

  1. Islands architecture: React components are isolated interactive areas in server-rendered HTML
  2. Three layers: Entry components, feature components, and UI primitives
  3. Clear boundaries: Each component has one job and does it well
  4. Props design: Separate data props from UI props, support controlled and uncontrolled patterns
  5. Composition: Build complex UIs from simple, focused components
  6. State boundaries: Keep state as local as possible, lift only when needed
  7. 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...