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:
controller.subscribe(callback) — built-in, no imports needed
subscribe(signal, callback) — standalone utility for any signal
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>
)
}