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>
    `
  }
}

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>
  )
}