> ## Documentation Index
> Fetch the complete documentation index at: https://docs.uselayers.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Framework integration

> Patterns for consuming Layers SDK reactive state in React, Vue, Svelte, Lit, and vanilla JavaScript, including hooks, stores, and signal subscriptions.

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:

```typescript theme={null}
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:

```tsx theme={null}
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:

```tsx theme={null}
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:

```vue theme={null}
<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`:

```svelte theme={null}
<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`:

```typescript theme={null}
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](/sdk/installation#loading-from-a-cdn) for alternative CDNs and version pinning.

```html theme={null}
<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.

```html theme={null}
<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.

```html theme={null}
<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](https://alpinejs.dev/globals/alpine-store) so every component subscribes to the same signal.

```html theme={null}
<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>
```

<Tip>
  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.
</Tip>

## 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:

```typescript theme={null}
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:

```typescript theme={null}
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:

```tsx theme={null}
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>
  )
}
```
