Skip to main content
The SDK’s reactive state is built on framework-agnostic signals. Every controller exposes three ways to consume state changes — you can pick whichever fits your stack:
  1. controller.subscribe(callback) — built-in, no imports needed
  2. subscribe(signal, callback) — standalone utility for any signal
  3. effect(() => { ... }) — direct signal access for custom reactivity
All examples below use a collection controller, but the same patterns apply to search, blocks, suggest, searchContent, and createProductCard.

Vanilla JavaScript

Use subscribe to drive DOM updates directly:
import { createClient } from '@commerce-blocks/sdk'

const { data: client } = createClient({
  token: 'your-token',
  sorts: [{ name: 'Featured', code: 'featured' }],
  facets: [{ name: 'Color', code: 'options.color' }],
})

const collection = client.collection({ handle: 'shirts' })

const grid = document.getElementById('product-grid')
const loader = document.getElementById('loader')

const unsubscribe = collection.subscribe(({ data, error, isFetching }) => {
  loader.hidden = !isFetching

  if (error) {
    grid.innerHTML = `<p>Error: ${error.message}</p>`
    return
  }

  if (data) {
    grid.innerHTML = data.products
      .map((p) => `<div class="product-card"><h3>${p.title}</h3></div>`)
      .join('')
  }
})

await collection.execute()

// Cleanup when done
unsubscribe()
collection.dispose()

React

Wrap the SDK’s subscribe in a hook using React’s useSyncExternalStore for tear-free reads:
import { useSyncExternalStore, useMemo, useEffect } from 'react'
import { getClient, type QueryState } from '@commerce-blocks/sdk'

function useLayersSubscription<T>(
  createController: () => { 
    state: { value: QueryState<T> }
    subscribe: (cb: (state: QueryState<T>) => void) => () => void
    dispose: () => void
  },
  deps: unknown[] = [],
) {
  const controller = useMemo(() => createController(), deps)

  useEffect(() => {
    return () => controller.dispose()
  }, [controller])

  return useSyncExternalStore(
    (onStoreChange) => controller.subscribe(() => onStoreChange()),
    () => controller.state.value,
  )
}
Usage:
function CollectionGrid({ handle }: { handle: string }) {
  const { data: client } = getClient()
  const { data, error, isFetching } = useLayersSubscription(
    () => client.collection({ handle }),
    [handle],
  )

  if (isFetching) return <p>Loading...</p>
  if (error) return <p>Error: {error.message}</p>
  if (!data) return null

  return (
    <div className="product-grid">
      {data.products.map((product) => (
        <div key={product.id} className="product-card">
          <h3>{product.title}</h3>
        </div>
      ))}
    </div>
  )
}

Vue

Use Vue’s ref and onUnmounted to bridge SDK state:
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { getClient, type CollectionResult, type ClientError } from '@commerce-blocks/sdk'

const props = defineProps<{ handle: string }>()

const products = ref<CollectionResult | null>(null)
const error = ref<ClientError | null>(null)
const isFetching = ref(false)

const { data: client } = getClient()
const collection = client.collection({ handle: props.handle })

const unsubscribe = collection.subscribe((state) => {
  products.value = state.data
  error.value = state.error
  isFetching.value = state.isFetching
})

onMounted(() => {
  collection.execute()
})

onUnmounted(() => {
  unsubscribe()
  collection.dispose()
})
</script>

<template>
  <p v-if="isFetching">Loading...</p>
  <p v-else-if="error">Error: {{ error.message }}</p>
  <div v-else-if="products" class="product-grid">
    <div v-for="product in products.products" :key="product.id" class="product-card">
      <h3>{{ product.title }}</h3>
    </div>
  </div>
</template>

Svelte

Use the subscribe callback to drive Svelte’s reactive $state:
<script lang="ts">
  import { onDestroy } from 'svelte'
  import { getClient } from '@commerce-blocks/sdk'

  const { handle }: { handle: string } = $props()

  const { data: client } = getClient()
  const collection = client.collection({ handle })

  let products = $state(null)
  let error = $state(null)
  let isFetching = $state(false)

  const unsubscribe = collection.subscribe((state) => {
    products = state.data
    error = state.error
    isFetching = state.isFetching
  })

  collection.execute()

  onDestroy(() => {
    unsubscribe()
    collection.dispose()
  })
</script>

{#if isFetching}
  <p>Loading...</p>
{:else if error}
  <p>Error: {error.message}</p>
{:else if products}
  <div class="product-grid">
    {#each products.products as product (product.id)}
      <div class="product-card">
        <h3>{product.title}</h3>
      </div>
    {/each}
  </div>
{/if}

Lit

Use subscribe in connectedCallback / disconnectedCallback:
import { LitElement, html } from 'lit'
import { customElement, property, state } from 'lit/decorators.js'
import { getClient, type CollectionResult, type ClientError } from '@commerce-blocks/sdk'

@customElement('product-grid')
export class ProductGrid extends LitElement {
  @property() handle = ''

  @state() private products: CollectionResult | null = null
  @state() private error: ClientError | null = null
  @state() private isFetching = false

  private controller?: ReturnType<ReturnType<typeof getClient>['data']['collection']>
  private unsubscribe?: () => void

  connectedCallback() {
    super.connectedCallback()
    const { data: client } = getClient()
    this.controller = client.collection({ handle: this.handle })

    this.unsubscribe = this.controller.subscribe((state) => {
      this.products = state.data
      this.error = state.error
      this.isFetching = state.isFetching
    })

    this.controller.execute()
  }

  disconnectedCallback() {
    super.disconnectedCallback()
    this.unsubscribe?.()
    this.controller?.dispose()
  }

  render() {
    if (this.isFetching) return html`<p>Loading...</p>`
    if (this.error) return html`<p>Error: ${this.error.message}</p>`
    if (!this.products) return html``

    return html`
      <div class="product-grid">
        ${this.products.products.map(
          (product) => html`
            <div class="product-card">
              <h3>${product.title}</h3>
            </div>
          `,
        )}
      </div>
    `
  }
}

Alpine.js

Alpine pairs well with the SDK in zero-build Shopify themes — boot the client from a CDN, then use an Alpine component to expose the controller state to your template. Hand the controller’s subscribe callback Alpine’s reactive $data and the DOM updates automatically as the SDK signal emits.

Boot the client once

Load the SDK and Alpine from a CDN, then create the client in a single <script type="module"> block in theme.liquid (or any layout). See Loading from a CDN for alternative CDNs and version pinning.
<script src="//unpkg.com/alpinejs" defer></script>

<script type="module">
  import { createClient } from 'https://unpkg.com/@commerce-blocks/sdk@2?module'

  createClient({
    token: '{{ shop.metafields.layers.embed_settings.value.storefrontApiToken }}',
    sorts: [{ name: 'Featured', code: 'featured' }],
    facets: [{ name: 'Color', code: 'options.color' }],
  })
</script>

Collection component

Define an Alpine component that owns a client.collection() controller. The init() lifecycle hook creates the controller, wires subscribe into Alpine’s reactive state, and triggers the initial execute(). destroy() tears the subscription and the controller down so navigating away never leaks signals.
<div
  x-data="layersCollection({ handle: 'shirts' })"
  x-init="init()"
  @destroy="destroy()"
>
  <template x-if="isFetching">
    <p>Loading…</p>
  </template>

  <template x-if="error">
    <p x-text="`Error: ${error.message}`"></p>
  </template>

  <ul class="product-grid">
    <template x-for="product in products" :key="product.id">
      <li class="product-card">
        <a :href="`/products/${product.handle}`">
          <h3 x-text="product.title"></h3>
          <span x-text="product.priceRange?.formatted"></span>
        </a>
      </li>
    </template>
  </ul>

  <button @click="loadMore()" x-show="page < totalPages">Load more</button>
</div>

<script type="module">
  import { getClient } from 'https://unpkg.com/@commerce-blocks/sdk@2?module'

  window.layersCollection = ({ handle }) => ({
    products: [],
    page: 1,
    totalPages: 1,
    isFetching: false,
    error: null,
    _controller: null,
    _unsubscribe: null,

    init() {
      const { data: client } = getClient()
      this._controller = client.collection({ handle })

      this._unsubscribe = this._controller.subscribe(({ data, error, isFetching }) => {
        this.isFetching = isFetching
        this.error = error

        if (data) {
          this.products = data.products
          this.page = data.page
          this.totalPages = data.totalPages
        }
      })

      this._controller.execute()
    },

    loadMore() {
      this._controller.execute({ page: this.page + 1 })
    },

    destroy() {
      this._unsubscribe?.()
      this._controller?.dispose()
    },
  })
</script>
The component reads data.products, data.page, and data.totalPages straight off the SDK state and exposes them as Alpine reactive properties — every signal emit re-renders the matching x-text, x-for, and x-show bindings.

Search component

The same pattern works for search. Reuse client.suggest() and client.search() so you get debouncing, abort handling, and the search_id prepare/execute lifecycle for free.
<div x-data="layersSearch()" x-init="init()" @destroy="destroy()">
  <input
    type="search"
    x-model="query"
    @input="suggest()"
    placeholder="Search…"
  />

  <ul x-show="suggestions.length" class="suggestions">
    <template x-for="s in suggestions" :key="s.query_text">
      <li>
        <a :href="`/search?q=${encodeURIComponent(s.query_text)}`" x-text="s.query_text"></a>
      </li>
    </template>
  </ul>
</div>

<script type="module">
  import { getClient } from 'https://unpkg.com/@commerce-blocks/sdk@2?module'

  window.layersSearch = () => ({
    query: '',
    suggestions: [],
    _suggest: null,
    _unsubscribe: null,

    init() {
      const { data: client } = getClient()
      this._suggest = client.suggest({ debounce: 200 })

      this._unsubscribe = this._suggest.subscribe(({ data }) => {
        if (data?._meta?.redirect?.url) {
          window.location.href = data._meta.redirect.url
          return
        }
        this.suggestions = data?.matchedQueries ?? []
      })
    },

    suggest() {
      const q = this.query.trim()
      if (!q) {
        this.suggestions = []
        return
      }
      this._suggest.execute(q)
    },

    destroy() {
      this._unsubscribe?.()
      this._suggest?.dispose()
    },
  })
</script>

Store for sharing controllers across components

If multiple Alpine components on the same page need the same controller (a header search box that drives a results grid, for example), wrap the controller in an Alpine store so every component subscribes to the same signal.
<script type="module">
  import { getClient } from 'https://unpkg.com/@commerce-blocks/sdk@2?module'

  document.addEventListener('alpine:init', () => {
    const { data: client } = getClient()
    const search = client.search()

    Alpine.store('layersSearch', {
      data: null,
      isFetching: false,
      error: null,
      controller: search,
      init() {
        search.subscribe((state) => {
          this.data = state.data
          this.isFetching = state.isFetching
          this.error = state.error
        })
      },
    })
  })
</script>

<input
  type="search"
  @change="$store.layersSearch.controller.execute({ query: $event.target.value })"
/>

<div x-data>
  <ul>
    <template x-for="p in $store.layersSearch.data?.products ?? []" :key="p.id">
      <li x-text="p.title"></li>
    </template>
  </ul>
</div>
Alpine evaluates x-data expressions inside with blocks, so accessing data?.priceRange?.formatted works just like it would in plain JS. Use optional chaining everywhere you read SDK state — the controller emits a state with data: null while the first request is in flight.

Using standalone subscribe

The SDK exports a subscribe utility that works with any signal, not just controller state. This is useful for watching computed values or composing signals:
import { subscribe, computed, effect } from '@commerce-blocks/sdk'

const collection = client.collection({ handle: 'shirts' })

// Watch just the product count
const productCount = computed(() => {
  const { data } = collection.state.value
  return data?.products.length ?? 0
})

const unsubscribe = subscribe(productCount, (count) => {
  document.getElementById('count').textContent = `${count} products`
})

Using effect directly

For maximum flexibility, use effect to react to any combination of signals:
import { effect } from '@commerce-blocks/sdk'

const search = client.search({ query: 'ring' })
const collection = client.collection({ handle: 'shirts' })

const dispose = effect(() => {
  const searchState = search.state.value
  const collectionState = collection.state.value

  const isAnyLoading = searchState.isFetching || collectionState.isFetching
  document.getElementById('global-loader').hidden = !isAnyLoading
})

// Cleanup
dispose()

ProductCard integration

The createProductCard controller follows the same patterns. Here’s a React example:
import { useSyncExternalStore, useMemo, useEffect, useRef } from 'react'
import { createProductCard, type ProductCardState } from '@commerce-blocks/sdk'

function getCardSnapshot(card: ReturnType<typeof createProductCard>) {
  return {
    selectedVariant: card.selectedVariant.value,
    options: card.options.value,
    price: card.price.value,
    images: card.images.value,
  }
}

function ProductCard({ product }: { product: Product }) {
  const card = useMemo(() => createProductCard({ product }), [product])
  const snapshotRef = useRef(getCardSnapshot(card))

  useEffect(() => {
    return () => card.dispose()
  }, [card])

  const state = useSyncExternalStore(
    (onStoreChange) => card.subscribe(() => {
      snapshotRef.current = getCardSnapshot(card)
      onStoreChange()
    }),
    () => snapshotRef.current,
  )

  return (
    <div>
      <h3>{product.title}</h3>
      {state.options.map((group) => (
        <div key={group.name}>
          <label>{group.name}</label>
          {group.values.map((opt) => (
            <button
              key={opt.value}
              disabled={opt.status === 'unavailable'}
              data-selected={opt.selected}
              onClick={() => cardRef.current.selectOption({ name: group.name, value: opt.value })}
            >
              {opt.value}
              {opt.status === 'sold-out' && ' (Sold out)'}
            </button>
          ))}
        </div>
      ))}
      {state.price.price && <p>{state.price.price.formatted}</p>}
    </div>
  )
}