Compiling ideas into code…
Wiring up APIs and UI…
Sit back for a moment while the site gets ready.
ShopifyThemes

React.js ShopifyTheme Development

When a Theme Needs More Than Enhancement

Most Shopify theme interactions are localized: a dropdown toggles, a variant selector updates a price, a cart drawer slides in. Alpine.js handles these at 4KB. Vue scales to collection filtering and shared state at 34KB. But some interactions are genuinely complex, deep component trees where child components conditionally render other children, state flows that branch across multiple steps, real-time previews that need to reconcile selections from four different inputs simultaneously.

A product customizer is the canonical example. A customer selects a box size, browses a catalog to pick products that fit, writes a personalized message, and sees a live preview with a running price total that updates as they add or remove items. Each step validates before allowing progression. The preview component needs to render selections from the size step, the product picker, and the message editor, all at once, all reactive. This isn't a "sprinkle some interactivity" problem. It's a component architecture problem.

This is where React earns its ~42KB runtime cost. Its component composition model, JSX, hooks, unidirectional data flow, was designed for exactly these deeply nested, state-heavy UIs. The question isn't "should I use React" but "is this interaction complex enough to justify the weight?" If your component tree is three levels deep and your state mutations number more than a handful, the answer is usually yes.


The Cost of React in a Theme

Let's be honest about what React costs before building anything with it. React 18 plus ReactDOM is approximately 42KB gzipped, before any state library, before any application code. That's nearly three times Vue's runtime-only build and ten times Alpine.js. On a Shopify theme where Shopify recommends keeping JavaScript bundles under 16KB, this is a significant commitment.

The cost is justified when the alternative is worse. A multi-step customizer built with vanilla JavaScript becomes an unmaintainable tangle of DOM queries, event listeners, and implicit state. The same feature in Alpine.js stretches its flat x-data scoping to the breaking point. React's component model and hooks system provide the structure that complex UIs demand, and the 42KB overhead is amortized across the complexity it manages.

For teams concerned about bundle size, Preact is a viable escape hatch: ~3KB gzipped with a compatibility layer that supports most React APIs. We'll cover the tradeoffs later in this article.

React in a theme is not React as an SPA. We're not building a single-page application. We're mounting isolated React "islands" onto Liquid-rendered DOM nodes. The page loads as server-rendered HTML (fast, SEO-indexed), and React takes over specific interactive regions. This is fundamentally different from a Create React App or Next.js architecture.


The Case Study: Custom Gift Box Builder

Here's the business requirement: build a product customizer where customers create a gift box. The flow has four steps:

  1. Choose box size, Small (3 items), Medium (5 items), or Large (8 items). Each size has a different base price
  2. Pick products, Browse a catalog of eligible products, search by name, and add items to the box. The UI enforces the capacity limit and shows remaining slots
  3. Add a message, Write a personalized note (max 200 characters) and select a card style
  4. Review, Live preview showing all selections with a running price total. Confirm and add the entire configuration to the Shopify cart

This requires state that spans all four steps: the box size affects the product picker's capacity, the selected products affect the price, and the review step reads from everything. It's a textbook case for React's component model and centralized state management.


Island Architecture with createRoot()

React 18 introduced createRoot() as the primary mounting API. For Shopify theme islands, this is always the right choice, not hydrateRoot(). Hydration expects server-rendered React HTML that matches the client-side component tree. Liquid isn't rendering React components; it's rendering Liquid templates. The mount points are empty containers that React fills client-side.

Basic island mounting
import { createRoot } from 'react-dom/client'

const el = document.getElementById('gift-box-customizer')
if (el) {
  const props = JSON.parse(el.dataset.props || '{}')
  const root = createRoot(el, {
    identifierPrefix: 'customizer-'
  })
  root.render(<GiftBoxCustomizer {...props} />)
}

The identifierPrefix option prevents ID collisions when multiple React roots exist on the same page, each root generates unique IDs for accessibility attributes like aria-labelledby. This matters when you have a customizer island and a reviews island on the same product page.

For themes with multiple React islands across different pages, a generic mounting system saves repetition:

frontend/entrypoints/islands.jsx
import { createRoot } from 'react-dom/client'

const registry = {
  'gift-box': () => import('../components/GiftBoxCustomizer'),
  'reviews': () => import('../components/ReviewsWidget'),
  'quick-view': () => import('../components/QuickView')
}

document.querySelectorAll('[data-react-island]').forEach(
  async (el) => {
    const name = el.dataset.reactIsland
    const loader = registry[name]
    if (!loader) return

    const module = await loader()
    const Component = module.default
    const props = JSON.parse(el.dataset.props || '{}')

    const root = createRoot(el, {
      identifierPrefix: `${name}-`
    })
    root.render(<Component {...props} />)
  }
)

The Liquid template creates the mount point and passes data:

sections/product-customizer.liquid
<div
  data-react-island="gift-box"
  data-props='{{ product | json | escape }}'>
  <!-- Fallback content for no-JS / loading state -->
  <noscript>
    <p>JavaScript is required for the gift box customizer.</p>
    <a href="{{ product.url }}">View standard product options</a>
  </noscript>
</div>

Dynamic imports enable code splitting. The registry pattern uses import() so each island's code is loaded only when that island exists on the page. A collection page that has no customizer doesn't download the customizer bundle.


Setting Up Vite for React

The build pipeline uses the same vite-plugin-shopify as the Vue setup, with the addition of React's JSX transform (which Vite handles automatically).

Terminal
npm init -y
npm install react react-dom zustand
npm install -D vite @vitejs/plugin-react vite-plugin-shopify
vite.config.js
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import shopify from 'vite-plugin-shopify'

export default defineConfig({
  plugins: [
    react(),
    shopify({
      themeRoot: './',
      sourceCodeDir: 'frontend',
      entrypointsDir: 'frontend/entrypoints'
    })
  ],
  build: {
    emptyOutDir: false
  }
})

The directory structure mirrors the Vue setup:

your-theme/
├── frontend/
│   ├── entrypoints/
│   │   └── islands.jsx
│   ├── components/
│   │   ├── GiftBoxCustomizer.jsx
│   │   ├── steps/
│   │   │   ├── BoxSizeSelector.jsx
│   │   │   ├── ProductPicker.jsx
│   │   │   ├── MessageEditor.jsx
│   │   │   └── ReviewStep.jsx
│   │   └── LazyIsland.jsx
│   └── stores/
│       └── customizer.js
├── sections/
├── snippets/
├── assets/
├── vite.config.js
└── package.json

Configure your package.json with scripts that handle the dual-server development workflow and production builds. Vite compiles JSX, applies tree-shaking to remove unused React internals, and minifies the output, production bundles are typically 60-70% smaller than the development source:

package.json
{
  "name": "your-shopify-theme",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "dev:shopify": "shopify theme dev --store your-store.myshopify.com",
    "dev:all": "concurrently \"npm run dev\" \"npm run dev:shopify\"",
    "deploy": "vite build && shopify theme push",
    "preview": "vite preview"
  },
  "dependencies": {
    "react": "^18.3.0",
    "react-dom": "^18.3.0",
    "zustand": "^5.0.0"
  },
  "devDependencies": {
    "vite": "^6.0.0",
    "@vitejs/plugin-react": "^4.4.0",
    "vite-plugin-shopify": "^4.0.0",
    "concurrently": "^9.0.0"
  }
}

Each script serves a specific role in the workflow:

  • npm run dev, Starts Vite with HMR. React Fast Refresh is enabled by default through @vitejs/plugin-react, giving you instant component updates without losing state
  • npm run build, Produces optimized production bundles. Vite tree-shakes unused React APIs, applies minification, and splits shared dependencies (Zustand, React) into separate chunks that browsers cache independently
  • npm run dev:all, Runs both Vite and Shopify CLI dev servers in parallel with concurrently. This is the primary development command
  • npm run deploy, Builds production assets, then pushes the theme to Shopify. Always build before pushing, the vite-tag snippet falls back to built assets when the dev server isn't running
  • npm run preview, Serves the production build locally. Useful for verifying that tree-shaking and code splitting haven't broken anything before deploying

Preact users: If you switch to Preact later (see the Preact Escape Hatch section below), swap react and react-dom for preact in your dependencies and add the Vite aliases. The scripts stay identical, the build pipeline doesn't change.

Wire it into your Liquid layout using the vite-tag snippet. Load the island entry point only on pages that need it:

layout/theme.liquid
{%- if template.name == 'product' and product.type == 'Gift Box' -%}
  {% render 'vite-tag' with 'islands.jsx' %}
{%- endif -%}

Before deploying, create a .shopifyignore file to prevent shopify theme push from uploading source files, build configuration, and node_modules to Shopify:

.shopifyignore
frontend/
node_modules/
package.json
package-lock.json
vite.config.js
.env
.gitignore

For production stores with third-party apps that inject files into the theme, use --nodelete to avoid removing remote-only files:

Terminal
# Safe deployment: build + push without deleting remote-only files
npm run build && shopify theme push --nodelete

Automating Builds with GitHub Actions

When multiple developers work on the same theme, or when deploying from a staging branch, you don't want builds to depend on someone remembering to run npm run build locally. A GitHub Actions workflow automates this: every push to your deployment branch triggers a Vite build, and the compiled assets are committed back so the branch always contains production-ready files.

.github/workflows/build-assets.yml
name: Build assets with Vite

on:
  push:
    branches:
      - staging

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '22'

      - name: Install pnpm
        run: npm install -g pnpm

      - name: Install dependencies
        run: pnpm install

      - name: Build assets with Vite
        run: pnpm run build

      - name: Commit generated assets
        run: |
          git config --global user.name 'github-actions[bot]'
          git config --global user.email '41898282+github-actions[bot]@users.noreply.github.com'
          git add assets/
          git commit -m 'Auto-build: compiled Vite assets' || echo "No changes to commit"
          git push origin staging

This is particularly valuable for React themes because the build step does more heavy lifting than with Alpine or even Vue: JSX transformation, tree-shaking of unused React internals, Zustand store dead-code elimination, and chunk splitting for lazy-loaded islands. The production bundles are often 60-70% smaller than the source, you want that optimization to happen consistently, not depend on a developer's local environment.

Target git add assets/, not git add .. Vite outputs compiled files to assets/. Adding everything risks committing lock file diffs or other CI artifacts. If vite-plugin-shopify also generates a vite-tag Liquid snippet, include snippets/vite-tag.liquid in the add command as well.

Connecting to Shopify. Once the staging branch contains built assets, you can connect it to Shopify via the GitHub integration for automatic theme syncing, or deploy manually with shopify theme push --nodelete. Either way, no one needs Node.js installed to deploy, the CI pipeline handles the build.


Step 1: State Management with Zustand

React Context is the default state solution, but it has a critical problem for island architectures: every Context consumer re-renders when any part of the context value changes. For a multi-step customizer where the preview component reads from box size, selected products, and the message simultaneously, this means unnecessary re-renders on every keystroke in the message field.

Zustand solves this with selector-based subscriptions: each component subscribes to only the slices of state it needs. When the message changes, only the message editor and the preview's message section re-render, not the product picker, not the box size selector.

Zustand also works across multiple createRoot() calls without wrapping anything in a Provider. The store is a plain JavaScript module, any component that imports it shares the same state.

frontend/stores/customizer.js
import { create } from 'zustand'

const BOX_SIZES = {
  small: { label: 'Small', capacity: 3, basePrice: 1500 },
  medium: { label: 'Medium', capacity: 5, basePrice: 2500 },
  large: { label: 'Large', capacity: 8, basePrice: 3500 }
}

export const useCustomizerStore = create((set, get) => ({
  currentStep: 0,
  boxSize: null,
  selectedProducts: [],
  message: '',
  cardStyle: 'classic',

  steps: ['size', 'products', 'message', 'review'],

  setBoxSize: (size) => set({
    boxSize: size,
    selectedProducts: get().selectedProducts.slice(
      0, BOX_SIZES[size]?.capacity || 0
    )
  }),

  addProduct: (product) => set((state) => {
    const capacity = BOX_SIZES[state.boxSize]?.capacity || 0
    if (state.selectedProducts.length >= capacity) return state
    if (state.selectedProducts.find(p => p.id === product.id)) return state
    return { selectedProducts: [...state.selectedProducts, product] }
  }),

  removeProduct: (productId) => set((state) => ({
    selectedProducts: state.selectedProducts.filter(
      p => p.id !== productId
    )
  })),

  setMessage: (message) => set({
    message: message.slice(0, 200)
  }),

  setCardStyle: (style) => set({ cardStyle: style }),

  nextStep: () => set((state) => {
    if (!get().canProceed()) return state
    return { currentStep: Math.min(state.currentStep + 1, 3) }
  }),

  prevStep: () => set((state) => ({
    currentStep: Math.max(state.currentStep - 1, 0)
  })),

  goToStep: (step) => set({ currentStep: step }),

  canProceed: () => {
    const state = get()
    switch (state.currentStep) {
      case 0: return state.boxSize !== null
      case 1: return state.selectedProducts.length > 0
      case 2: return true
      case 3: return true
      default: return false
    }
  },

  get totalPrice() {
    const state = get()
    const base = BOX_SIZES[state.boxSize]?.basePrice || 0
    const productsTotal = state.selectedProducts.reduce(
      (sum, p) => sum + p.price, 0
    )
    return base + productsTotal
  },

  get remainingSlots() {
    const state = get()
    const capacity = BOX_SIZES[state.boxSize]?.capacity || 0
    return capacity - state.selectedProducts.length
  },

  BOX_SIZES
}))

Several design decisions worth noting:

  • setBoxSize trims selected products, If a customer downsizes from Large (8 items) to Small (3 items), excess products are automatically removed. Without this, the store would hold invalid state
  • addProduct enforces capacity and deduplication, The function is a no-op if the box is full or the product is already selected. State mutations are always valid
  • canProceed is step-aware, Each step has its own validation. The "Next" button disables when the current step isn't complete
  • Prices are in cents, Following Shopify's convention. Formatting happens at the UI layer

Step 2: Building the Customizer Steps

The main customizer component renders the current step based on the store's currentStep value. Each step is a separate component that subscribes to only the state slices it needs.

frontend/components/GiftBoxCustomizer.jsx
import { useCustomizerStore } from '../stores/customizer'
import BoxSizeSelector from './steps/BoxSizeSelector'
import ProductPicker from './steps/ProductPicker'
import MessageEditor from './steps/MessageEditor'
import ReviewStep from './steps/ReviewStep'

const STEP_COMPONENTS = [
  BoxSizeSelector,
  ProductPicker,
  MessageEditor,
  ReviewStep
]

export default function GiftBoxCustomizer({ product }) {
  const currentStep = useCustomizerStore(s => s.currentStep)
  const steps = useCustomizerStore(s => s.steps)
  const canProceed = useCustomizerStore(s => s.canProceed)
  const nextStep = useCustomizerStore(s => s.nextStep)
  const prevStep = useCustomizerStore(s => s.prevStep)
  const totalPrice = useCustomizerStore(s => s.totalPrice)

  const StepComponent = STEP_COMPONENTS[currentStep]

  function formatMoney(cents) {
    return (cents / 100).toLocaleString(undefined, {
      style: 'currency',
      currency: window.Shopify?.currency?.active || 'USD'
    })
  }

  return (
    <div className="gift-customizer">
      <nav className="step-indicators">
        {steps.map((label, idx) => (
          <span
            key={label}
            className={`step-dot ${
              idx === currentStep ? 'active' : ''
            } ${idx < currentStep ? 'completed' : ''}`}>
            {idx + 1}. {label}
          </span>
        ))}
      </nav>

      <div className="step-content">
        <StepComponent product={product} />
      </div>

      <footer className="step-footer">
        <div className="running-total">
          Total: {formatMoney(totalPrice)}
        </div>
        <div className="step-nav">
          {currentStep > 0 && (
            <button onClick={prevStep} className="btn-prev">
              Back
            </button>
          )}
          {currentStep < 3 && (
            <button
              onClick={nextStep}
              disabled={!canProceed()}
              className="btn-next">
              Next
            </button>
          )}
        </div>
      </footer>
    </div>
  )
}

The BoxSizeSelector is the simplest step, three options that set the box size:

frontend/components/steps/BoxSizeSelector.jsx
import { useCustomizerStore } from '../../stores/customizer'

export default function BoxSizeSelector() {
  const boxSize = useCustomizerStore(s => s.boxSize)
  const setBoxSize = useCustomizerStore(s => s.setBoxSize)
  const { BOX_SIZES } = useCustomizerStore.getState()

  function formatMoney(cents) {
    return (cents / 100).toLocaleString(undefined, {
      style: 'currency',
      currency: window.Shopify?.currency?.active || 'USD'
    })
  }

  return (
    <div className="box-size-step">
      <h3>Choose Your Box Size</h3>
      <div className="size-options">
        {Object.entries(BOX_SIZES).map(([key, config]) => (
          <button
            key={key}
            onClick={() => setBoxSize(key)}
            className={`size-option ${boxSize === key ? 'selected' : ''}`}>
            <span className="size-label">{config.label}</span>
            <span className="size-capacity">{config.capacity} items</span>
            <span className="size-price">
              from {formatMoney(config.basePrice)}
            </span>
          </button>
        ))}
      </div>
    </div>
  )
}

The ProductPicker is the most complex step, it shows a searchable catalog and enforces the capacity limit:

frontend/components/steps/ProductPicker.jsx
import { useState, useMemo } from 'react'
import { useCustomizerStore } from '../../stores/customizer'

export default function ProductPicker({ product }) {
  const selectedProducts = useCustomizerStore(s => s.selectedProducts)
  const addProduct = useCustomizerStore(s => s.addProduct)
  const removeProduct = useCustomizerStore(s => s.removeProduct)
  const remainingSlots = useCustomizerStore(s => s.remainingSlots)

  const [search, setSearch] = useState('')
  const [catalog] = useState(() =>
    JSON.parse(
      document.getElementById('eligible-products')?.textContent || '[]'
    )
  )

  const filtered = useMemo(() =>
    catalog.filter(p =>
      p.title.toLowerCase().includes(search.toLowerCase())
    ),
    [catalog, search]
  )

  const isSelected = (id) =>
    selectedProducts.some(p => p.id === id)

  function formatMoney(cents) {
    return (cents / 100).toLocaleString(undefined, {
      style: 'currency',
      currency: window.Shopify?.currency?.active || 'USD'
    })
  }

  return (
    <div className="product-picker-step">
      <div className="picker-header">
        <h3>Pick Your Products</h3>
        <span className="slots-remaining">
          {remainingSlots} slot{remainingSlots !== 1 ? 's' : ''} remaining
        </span>
      </div>

      <input
        type="search"
        value={search}
        onChange={e => setSearch(e.target.value)}
        placeholder="Search products..."
        className="picker-search" />

      <div className="picker-grid">
        {filtered.map(item => (
          <div
            key={item.id}
            className={`picker-card ${isSelected(item.id) ? 'selected' : ''}`}>
            <img
              src={item.featured_image}
              alt={item.title}
              width="120"
              height="120"
              loading="lazy" />
            <p className="picker-title">{item.title}</p>
            <p className="picker-price">{formatMoney(item.price)}</p>
            {isSelected(item.id) ? (
              <button
                onClick={() => removeProduct(item.id)}
                className="btn-remove">
                Remove
              </button>
            ) : (
              <button
                onClick={() => addProduct(item)}
                disabled={remainingSlots === 0}
                className="btn-add">
                {remainingSlots === 0 ? 'Box Full' : 'Add'}
              </button>
            )}
          </div>
        ))}
      </div>

      {selectedProducts.length > 0 && (
        <div className="selected-summary">
          <h4>Selected ({selectedProducts.length})</h4>
          {selectedProducts.map(p => (
            <span key={p.id} className="selected-chip">
              {p.title}
              <button onClick={() => removeProduct(p.id)}>&times;</button>
            </span>
          ))}
        </div>
      )}
    </div>
  )
}

Notice the Zustand selector pattern: useCustomizerStore(s => s.remainingSlots) subscribes only to the remainingSlots derived value. When the customer types in the search field, the search state is local to the component (useState), it doesn't touch the global store, so no other component re-renders.


Step 3: Adding to Cart with Properties

The final step adds the gift box to the Shopify cart as a single line item with line item properties that capture the customization details. These properties appear in the order confirmation email and admin order view.

frontend/components/steps/ReviewStep.jsx
import { useState } from 'react'
import { useCustomizerStore } from '../../stores/customizer'

export default function ReviewStep({ product }) {
  const boxSize = useCustomizerStore(s => s.boxSize)
  const selectedProducts = useCustomizerStore(s => s.selectedProducts)
  const message = useCustomizerStore(s => s.message)
  const cardStyle = useCustomizerStore(s => s.cardStyle)
  const totalPrice = useCustomizerStore(s => s.totalPrice)
  const { BOX_SIZES } = useCustomizerStore.getState()

  const [isSubmitting, setIsSubmitting] = useState(false)
  const [error, setError] = useState(null)

  function formatMoney(cents) {
    return (cents / 100).toLocaleString(undefined, {
      style: 'currency',
      currency: window.Shopify?.currency?.active || 'USD'
    })
  }

  async function addToCart() {
    setIsSubmitting(true)
    setError(null)

    const rootUrl = window.Shopify?.routes?.root || '/'
    const properties = {
      'Box Size': BOX_SIZES[boxSize].label,
      'Items': selectedProducts.map(p => p.title).join(', '),
      '_item_ids': selectedProducts.map(p => p.id).join(','),
      'Card Style': cardStyle
    }

    if (message) {
      properties['Gift Message'] = message
    }

    try {
      const res = await fetch(rootUrl + 'cart/add.js', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          items: [{
            id: product.variants[0].id,
            quantity: 1,
            properties
          }]
        })
      })

      if (!res.ok) {
        const data = await res.json()
        throw new Error(data.description || 'Could not add to cart')
      }

      window.location.href = rootUrl + 'cart'
    } catch (err) {
      setError(err.message)
    } finally {
      setIsSubmitting(false)
    }
  }

  return (
    <div className="review-step">
      <h3>Review Your Gift Box</h3>

      <div className="review-section">
        <h4>{BOX_SIZES[boxSize].label} Box</h4>
        <div className="review-products">
          {selectedProducts.map(p => (
            <div key={p.id} className="review-item">
              <img src={p.featured_image} alt={p.title} width="60" height="60" />
              <span>{p.title}</span>
              <span>{formatMoney(p.price)}</span>
            </div>
          ))}
        </div>
      </div>

      {message && (
        <div className="review-section">
          <h4>Gift Message ({cardStyle})</h4>
          <p className="review-message">{message}</p>
        </div>
      )}

      <div className="review-total">
        <strong>Total: {formatMoney(totalPrice)}</strong>
      </div>

      {error && (
        <p className="error-message">{error}</p>
      )}

      <button
        onClick={addToCart}
        disabled={isSubmitting}
        className="btn-add-to-cart">
        {isSubmitting ? 'Adding to Cart...' : 'Add Gift Box to Cart'}
      </button>
    </div>
  )
}

Properties prefixed with _ (underscore) are hidden from the customer. The _item_ids property stores the raw product IDs for order fulfillment logic without cluttering the customer-facing order confirmation. Non-prefixed properties like "Box Size" and "Gift Message" appear in the cart, checkout, and order emails.


Lazy Hydration for Performance

Not every React island needs to mount immediately when the page loads. A product customizer above the fold should hydrate eagerly, any delay impacts conversion. But a reviews widget or a "You may also like" carousel below the fold can wait until the user scrolls to it.

Lazy hydration defers createRoot() until an island enters the viewport, using an IntersectionObserver. This reduces Total Blocking Time (TBT) because the main thread isn't spending time rendering components the user hasn't seen yet.

frontend/components/LazyIsland.jsx
import { createRoot } from 'react-dom/client'

export function mountLazy(el, loader, props = {}) {
  const observer = new IntersectionObserver(
    (entries) => {
      entries.forEach(async (entry) => {
        if (!entry.isIntersecting) return

        observer.unobserve(el)

        const module = await loader()
        const Component = module.default

        const root = createRoot(el, {
          identifierPrefix: `lazy-${el.id}-`
        })
        root.render(<Component {...props} />)
      })
    },
    { rootMargin: '200px' }
  )

  observer.observe(el)
  return observer
}

Use it in the island mounting script:

frontend/entrypoints/islands.jsx (with lazy support)
import { createRoot } from 'react-dom/client'
import { mountLazy } from '../components/LazyIsland'

const registry = {
  'gift-box': {
    loader: () => import('../components/GiftBoxCustomizer'),
    lazy: false
  },
  'reviews': {
    loader: () => import('../components/ReviewsWidget'),
    lazy: true
  }
}

document.querySelectorAll('[data-react-island]').forEach(
  async (el) => {
    const name = el.dataset.reactIsland
    const entry = registry[name]
    if (!entry) return

    const props = JSON.parse(el.dataset.props || '{}')

    if (entry.lazy) {
      mountLazy(el, entry.loader, props)
    } else {
      const module = await entry.loader()
      const root = createRoot(el, {
        identifierPrefix: `${name}-`
      })
      root.render(<module.default {...props} />)
    }
  }
)

The rootMargin: '200px' setting starts loading the component 200px before it enters the viewport, so the user sees content instead of a loading flash. For eagerly hydrated islands (the gift box customizer), the component mounts immediately.


The Preact Escape Hatch

If React's 42KB runtime is a hard stop for your performance budget, Preact offers a drop-in replacement at ~3KB gzipped. With the preact/compat layer, most React code works unchanged, hooks, context, refs, and even many third-party libraries.

The switch is a Vite configuration change:

vite.config.js (Preact alias)
import { defineConfig } from 'vite'
import preact from '@preact/preset-vite'
import shopify from 'vite-plugin-shopify'

export default defineConfig({
  plugins: [
    preact(),
    shopify({
      themeRoot: './',
      sourceCodeDir: 'frontend',
      entrypointsDir: 'frontend/entrypoints'
    })
  ],
  resolve: {
    alias: {
      'react': 'preact/compat',
      'react-dom': 'preact/compat',
      'react-dom/client': 'preact/compat/client'
    }
  },
  build: {
    emptyOutDir: false
  }
})

The tradeoffs to be aware of:

  • ~39KB savings, The runtime drops from 42KB to ~3KB. For island architectures, this is significant
  • Zustand works with Preact, The selector-based subscription model is framework-agnostic
  • Some React 18 features are absent, useTransition, useDeferredValue, and Suspense for data fetching have limited or no Preact support. If your customizer uses these, stick with React
  • React DevTools limited, The Preact DevTools extension works but provides less detail than React's. For complex debugging, this matters

For a Shopify theme where the React islands are focused (a customizer, a reviews widget) and don't use advanced React 18 concurrent features, Preact is a practical choice that cuts the framework cost by over 90%.


Edge Cases You Need to Handle

Cart line item properties have a 255-character limit per value. If your customizer allows selecting many products, the "Items" property string can exceed this. Either abbreviate the product titles, split across multiple properties (Items_1, Items_2), or store a JSON blob in a hidden property and use a theme app extension to render it in the cart.

Cleanup on unmount. React roots that aren't properly unmounted leak memory. If your theme uses AJAX page transitions, call root.unmount() when navigating away from a page with React islands. Track roots in a registry:

Root cleanup pattern
const activeRoots = []

function mount(el, Component, props) {
  const root = createRoot(el)
  root.render(<Component {...props} />)
  activeRoots.push(root)
  return root
}

function cleanup() {
  activeRoots.forEach(root => root.unmount())
  activeRoots.length = 0
}

document.addEventListener('page:before-change', cleanup)

Shopify preview mode. The Shopify theme editor wraps the storefront in an iframe and injects additional scripts. React DevTools may not connect in this context. Test your islands on the direct theme preview URL (your-store.myshopify.com?preview_theme_id=xxx) for DevTools access.

Catalog data for the product picker. The ProductPicker reads eligible products from a JSON script element. For large catalogs (100+ products), embedding this data inline increases page weight. Consider loading the catalog via the Storefront API on demand when the picker step activates, or paginating the embedded data.


Key Takeaways

  • React is the right tool for genuinely complex interactions. Multi-step customizers, deep component trees, and cross-component state flows justify React's 42KB overhead. For simpler enhancement, Alpine (4KB) or Vue (34KB) are better choices
  • Use createRoot(), not hydrateRoot(). Liquid-rendered HTML isn't React-rendered HTML. Mount React onto empty containers, not onto server-rendered component trees
  • Zustand over Context for multi-root architectures. No providers, selector-based subscriptions, works across independent createRoot() calls. Components only re-render when their specific state slice changes
  • Dynamic imports enable per-page code splitting. The island registry pattern loads only the components that exist on the current page. Collection pages don't download customizer code
  • Lazy hydration reduces TBT. Defer createRoot() for below-the-fold islands using IntersectionObserver. Mount critical islands eagerly
  • Line item properties capture customization data. Prefix with _ to hide from customers. Respect the 255-character limit per value
  • Preact is a viable 3KB alternative when you don't need React 18's concurrent features. The switch is a Vite config change

If your Shopify theme needs a product customizer, a complex configurator, or any interaction where component architecture and structured state management matter, I can help you build it with React islands that stay fast and maintainable. Let's talk about what your theme actually needs.

Scheduled Jobs with Redis for Shopify Apps