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

# State

> Reference for the Swish SDK reactive state API - track saved items, lists, product context, and item counts with signals.

The `swish.state` API provides reactive signals for tracking saved item state on the storefront. It's built on [`@preact/signals-core`](https://github.com/preactjs/signals) - values update automatically when underlying data changes, and any code wrapped in `effect()` re-runs when the signals it reads change.

Every sample on this page logs to the console. Paste one into the devtools of a store running Swish and watch the values update as you save, unsave, or navigate. The [examples](https://swish.app/docs/references/sdk/examples.md) page shows the same helpers driving real markup.

## `itemContext(source?)`

Resolves a product and variant context from a DOM element or `window.location`. Returns a `Signal<ItemContext>` that updates reactively when the URL or element attributes change.

The source accepts an `HTMLElement` or `window.location`:

```js theme={null}
// Resolve from a DOM element's data attributes or href
const context = swish.state.itemContext(element);

// Resolve from the current page URL
const context = swish.state.itemContext(window.location);

swish.state.effect(() => {
  const { productId, variantId, loading } = context.value;
  console.log({ productId, variantId, loading });
});
```

When an `HTMLElement` is passed, the product is resolved in this order:

1. If the element has `data-product-id` or `data-product-handle`, those values are used directly.
2. If the element has `data-item-id`, that saved item ID is used.
3. If no data attributes are found and the element is an anchor, the product is parsed from its `href`.

When `window.location` is passed, the product is always parsed from the current page URL. The SDK watches for SPA navigation (`pushState`, `replaceState`, `popstate`) and updates the context when the URL changes.

When only a `productHandle` is available, the SDK automatically fetches the `productId` from the Storefront API.

### `ItemContext`

| Property | Type | Description |
| - | - | - |
| `loading` | `boolean` | `true` while resolving the product ID from a handle. |
| `productHandle` | `string \| undefined` | The product handle, if resolved from a URL. |
| `productId` | `string \| undefined` | The Shopify product ID. |
| `variantId` | `string \| undefined` | The selected variant ID, if available. |
| `itemId` | `string \| undefined` | The saved item ID, if the source element has `data-item-id`. |

## `itemContextFromIds(ids)`

Builds an `itemContext` signal from explicit Shopify IDs instead of a DOM element or URL. Use this when you already have product, variant, or item IDs in hand - for example, when integrating with a framework that owns its own data layer (React, Vue) or building UI from server-rendered props.

```js theme={null}
const context = swish.state.itemContextFromIds({
  productId: "8234567890123",
  variantId: "44234567890123",
});

console.log(context.value);
// { loading: false, productId: "8234567890123", variantId: "44234567890123" }

const state = swish.state.itemState(context);
```

GIDs are accepted and normalized:

```js theme={null}
const context = swish.state.itemContextFromIds({
  productId: "gid://shopify/Product/8234567890123",
});
```

The returned signal has `loading: false` and is never updated automatically - it reflects exactly the IDs you passed in. To react to changing IDs (for example, when a parent component receives new props), construct a new context.

### `ItemContextIds`

| Property | Type | Required | Description |
| - | - | - | - |
| `productId` | `string \| number \| null` | No | Shopify product ID or GID. Numeric IDs are stringified. |
| `variantId` | `string \| number \| null` | No | Shopify variant ID or GID. Numeric IDs are stringified. |
| `itemId` | `string \| null` | No | Known saved item ID. Skips the lookup query when provided. |

## `itemState(context, options?)`

Takes an `itemContext` signal and returns a reactive signal with the full save state for that product, plus methods to save, unsave, and toggle.

```js theme={null}
const context = swish.state.itemContext(element);
const state = swish.state.itemState(context);

swish.state.effect(() => {
  console.log(state.value.status);
  console.log(state.value.saved);
});
```

### Options

| Option | Type | Default | Description |
| - | - | - | - |
| `match` | `"variant" \| "product" \| "auto"` | `"auto"` | How to interpret saved state. `"variant"`: only saved if the specific variant in the context is saved. `"product"`: saved if any variant of the product is saved, and `save()` ignores `variantId`. `"auto"`: variant-match when `variantId` is in the context, product-match otherwise. |
| `onSave` | `(data) => void` | - | Called after a successful save. Receives the saved item, product, and variant. |
| `onUnsave` | `(data) => void` | - | Called after a successful unsave. Receives the ID of the removed item. |
| `onListsUpdate` | `(data) => void` | - | Called after list assignments change via `state.updateLists()`. Receives the updated item, product, and variant. |
| `onVariantUpdate` | `(data) => void` | - | Called after the variant is changed via `state.updateVariant()`. Receives the updated item, product, and new variant. |
| `quantity` | `number` | `1` | Quantity for the item `save()` creates. |
| `tags` | `string[]` | - | Tags for the item `save()` creates (lowercase letters, numbers, hyphens, or underscores only). |

Use `match: "product"` on product cards that show a default variant but should report saved whenever the customer has saved any variant of the product. With `match: "product"`, `save()` also ignores `variantId` and saves the product, so the card's default variant isn't pinned to the saved item. `unsave()` always targets the currently picked `savedItemId`.

Callbacks can be replaced after construction with [`state.setOptions()`](#statesetoptionsoptions) - this is what the React `useSwishItem` hook uses to keep callback references current across re-renders without rebuilding the signal.

### State properties

| Property | Type | Description |
| - | - | - |
| `status` | `"unsaved" \| "saving" \| "saved"` | Current save lifecycle status. |
| `saved` | `boolean` | Whether the product is currently saved (per the `match` policy). |
| `saving` | `boolean` | `true` while a save operation is in progress. |
| `unsaving` | `boolean` | `true` while an unsave operation is in progress. |
| `loading` | `boolean` | `true` during initial load or any pending operation. |
| `submitting` | `boolean` | `true` while a save/unsave request is in flight. |
| `savedItemId` | `string \| null` | The ID of the saved item picked by the `match` policy, or `null` if not saved. |
| `savedItemIds` | `string[]` | Every saved item ID for the product, regardless of `match`. Useful for "saved N times" UIs. |
| `savedItemQuantity` | `number \| null` | Quantity stored on the `savedItemId` item; `null` when not saved or when `itemId` was passed in. |
| `savedItemVariantId` | `number \| null` | Variant stored on the `savedItemId` item; `null` when not saved, saved without a variant, or when `itemId` was passed in. |
| `error` | `ApiError \| null` | API error, if any. |

### Methods

#### `state.save()`

Saves the product using the `productId` and `variantId` from the context, and the `quantity` and `tags` options. Triggers the `save:item` intent. Calls `onSave` on success.

#### `state.unsave()`

Removes the saved item. Triggers the `unsave:item` intent. Calls `onUnsave` on success.

#### `state.toggle()`

Saves the product if it's unsaved, or unsaves it if it's saved. No-op while loading.

```js theme={null}
await state.toggle();
```

#### `state.updateLists()`

Opens the list editor for the saved item. Triggers the `edit:item-lists` intent. Calls `onListsUpdate` on success, or `onUnsave` if the customer removed the item from all lists.

#### `state.updateVariant()`

Opens the variant selector for the saved item. Triggers the `edit:item-variant` intent. Calls `onVariantUpdate` on success.

#### `state.setOptions(options)`

Replaces the callbacks, `match` policy, `quantity` and `tags` passed to `itemState`. Use this to update options without reconstructing the signal - this is how the React hook keeps callback references current across re-renders.

```js theme={null}
state.setOptions({
  match: "product",
  onSave: (data) => console.log("Saved item:", data.item.id),
});
```

#### `state.dispose()`

Tears down internal effects and query subscriptions. Call this when the consumer (component, custom element) unmounts to release the underlying signals.

> [!NOTE]
>
> `state.update()` is a deprecated alias for `state.updateLists()` and will be
> removed in a future major release.

## `itemCount()`

Returns a reactive signal that tracks the total number of saved items across all lists. Automatically refetches when items are added or removed.

```js theme={null}
const count = swish.state.itemCount();

swish.state.effect(() => {
  const { count: total, loading, refetching } = count.value;
  console.log({ total, loading, refetching });
});
```

### Count properties

| Property | Type | Description |
| - | - | - |
| `count` | `number` | Total number of saved items. Defaults to `0` while the first fetch is in flight. |
| `loading` | `boolean` | `true` during the first fetch only. |
| `refetching` | `boolean` | `true` while a later fetch is in flight (`item-create`, `item-delete`, or `token-update`). |
| `error` | `ApiError \| null` | API error, if any. |

A badge that hides itself while `loading` will not blink empty when a customer saves or unsaves. The last known `count` stays on the signal until the new number lands.

#### `count.dispose()`

Stops the refetch loop and releases the event listener. Call this when the consumer unmounts.

## `listContext(source)`

Resolves which collection of saved items a page or element is about. Returns a `Signal<ListContext>` with a `dispose()` method. It is the collection-level counterpart of [`itemContext`](#itemcontextsource) and feeds [`items()`](#itemscontext-options).

```js theme={null}
// Read the list from the current page URL
const context = swish.state.listContext(window.location);

// Read the list from an element's data attributes
const context = swish.state.listContext(element);

swish.state.effect(() => {
  const { listType, listId } = context.value;
  console.log({ listType, listId });
  // { listType: "all", listId: undefined } on /apps/wishlist
  // { listType: "user", listId: "abc123" } on /apps/wishlist?list-id=abc123
});
```

When `window.location` is passed, the SDK reads the same query parameters the built-in wishlist page and share links use, and updates the signal on SPA navigation (`pushState`, `replaceState`, `popstate`):

| URL | Resolves to |
| - | - |
| `?public-list-id=<id>` | `{ listId, listType: "shared" }` |
| `?list-id=recently-viewed` or `?list-id=saved-from-cart` | `{ listId, listType: "smart" }` |
| `?list-id=<id>` | `{ listId, listType: "user" }` |
| no list parameter | `{ listType: "all" }` |

When an `HTMLElement` is passed, `data-list-id` and `data-list-type` are read. Without `data-list-type`, the type is inferred from the id the same way as for a URL. An anchor without data attributes is parsed from its `href`.

Because the built-in share URL and the `view:list` intent both target `routes.listDetailPagePath`, a custom page mounted at that path receives shared-list and deep-link traffic with no extra wiring.

### `ListContext`

| Property | Type | Description |
| - | - | - |
| `listType` | `"all" \| "user" \| "smart" \| "shared"` | `all` is every saved item. `user` is a list the customer created. `smart` is a server-computed feed. `shared` is another customer's public list. |
| `listId` | `string \| undefined` | The list id. Absent for `all`. For `shared`, the public list id. |

#### `context.dispose()`

Restores the history hooks installed for a `window.location` source. A no-op for element sources.

## `lists(options?)`

Returns a reactive signal with the customer's lists, for tabs, sidebars, and list pickers. Refetches when a list is created, renamed, deleted, or its membership changes, and when the customer signs in.

```js theme={null}
const lists = swish.state.lists();

swish.state.effect(() => {
  const { lists: all, loading } = lists.value;
  if (loading) return;
  console.table(
    all.map(({ name, type, itemCount }) => ({ name, type, itemCount })),
  );
});
```

### Options

| Option | Type | Default | Description |
| - | - | - | - |
| `types` | `Array<"user" \| "smart">` | `["user", "smart"]` | Which kinds of list to load. User lists come first, then smart lists. |

### State properties

| Property | Type | Description |
| - | - | - |
| `lists` | `ListDetail[]` | The lists, each with `id`, `name`, `type`, `itemCount`, `previewImages`, `itemIds`. |
| `loading` | `boolean` | `true` during the first fetch only. |
| `refetching` | `boolean` | `true` while a later fetch is in flight. The previous lists stay on the signal. |
| `error` | `ApiError \| null` | API error, if any. |

The result does not include an "All items" entry. Build one from [`itemCount()`](#itemcount) and an `items({ listType: "all" })` collection, as in the [drawer example](#custom-drawer-with-list-tabs). Smart lists are feeds Swish fills for the customer, and you can't add items to them. The two available are `recently-viewed` and `saved-from-cart`. Remove a single item with [`swish.api.smartLists.removeItem`](https://swish.app/docs/references/sdk/api-client). The signal loads a single page of lists.

### Methods

#### `lists.refresh()`

Refetches the lists. Returns a promise that resolves when the fetch settles.

#### `lists.dispose()`

Stops refetching and releases the event listener.

## `items(context, options?)`

Returns a reactive, paginated collection of saved items for one list, or for everything the customer has saved. Takes a `listContext` signal or a plain `{ listId, listType }` object. Pass a signal you own and write to it to switch lists without rebuilding the collection.

```js theme={null}
const context = swish.state.listContext(window.location);
const items = swish.state.items(context);

swish.state.effect(() => {
  const { items: saved, loading, totalCount, hasMore } = items.value;
  if (loading) return;
  console.log({ totalCount, hasMore });
  console.log(saved.map((item) => [item.id, item.productId, item.variantId]));
});
```

The effect runs once per change to the collection, so there is nothing to copy or diff: read `items.value` and render from it. Fetch the next page with `loadMore()`, and the effect runs again with the longer list:

```js theme={null}
items.loadMore();
```

Because `items` reads the `listContext` signal, navigating to `?list-id=...` or `?public-list-id=...` switches the collection without a reload.

Each item carries Shopify IDs only (`id`, `productId`, `variantId`, `quantity`, `createdAt`). Resolve product data per card with [`product()`](#productids) so cards that never scroll into view never trigger a request.

The collection keeps itself in sync with the SDK's [events](https://swish.app/docs/references/sdk/events.md), whether the change came from your UI or the built-in Swish UI in the same tab. Changes made in another tab are not delivered; call `refresh()` if you need to pick those up:

| Event | Effect on the collection |
| - | - |
| `item-delete` | The item is removed locally and `totalCount` decremented. No request. |
| `item-update` | The item's `variantId` and `quantity` are patched locally. No request. |
| `item-create` | On an `all` collection with the default sort, the new item is prepended. Otherwise the first page is refetched. |
| `item-lists-update` | On a `user` collection, the item is dropped if it left this list, or the first page refetched if it joined. Ignored on other collection types. |
| `token-update` | The first page is refetched (the customer signed in, or a guest's first save created a profile). |

A refetch reloads the first page only. Items from later pages are replaced by that page, and `hasMore` reflects the new cursor.

### Options

| Option | Type | Default | Description |
| - | - | - | - |
| `limit` | `number` | `24` | Items per page. |
| `sort` | `"recently_saved" \| "custom"` | `"recently_saved"` | Sort order for `all` and `user` collections. Smart lists ignore it and log a warning. |

### State properties

| Property | Type | Description |
| - | - | - |
| `items` | `Item[]` | Every item loaded so far, first page first. |
| `totalCount` | `number \| null` | Total across all pages. `null` until the first page lands. |
| `hasMore` | `boolean` | Whether `loadMore()` has another page to fetch. |
| `loading` | `boolean` | `true` while the first page of the current context is in flight. Items are empty meanwhile. |
| `loadingMore` | `boolean` | `true` while `loadMore()` is in flight. |
| `refetching` | `boolean` | `true` while `refresh()` or an event-driven refetch is in flight. Items stay visible meanwhile. |
| `error` | `ApiError \| null` | API error, if any. |

> [!NOTE]
>
> A guest who has never saved anything has no Swish profile yet. The collection
> resolves to an empty page, not an error and not `loading`. Their first save
> creates the profile and fires `token-update`, which fills the collection.

### Methods

#### `items.loadMore()`

Fetches the next page and appends it. No-op when `hasMore` is `false` or a fetch is already in flight.

#### `items.refresh()`

Refetches the first page. The current items stay on the signal until the new page lands.

#### `items.dispose()`

Stops syncing, releases the event listener, and ignores any response still in flight.

Actions on items go through [intents](https://swish.app/docs/references/sdk/intents.md), so a custom page uses the same vocabulary as the built-in UI and the collection reacts to the resulting events:

```js theme={null}
swish.intents.invoke("unsave:item", { itemId: item.id });
swish.intents.invoke("edit:item-variant", { itemId: item.id });
swish.intents.invoke("create:cart-line", { variantId: item.variantId });
swish.intents.invoke("initiate:share", { listId: context.value.listId });
```

Deleting the list a collection is showing is not tracked. Subscribe to `list-delete` yourself if the page should navigate away.

## `product(ids)`

Returns a reactive signal with render-ready product data for one saved item. It wraps [`swish.storefront.loadResolvedProduct`](https://swish.app/docs/references/sdk/storefront) and shares its cache, so many cards for the same product make one request, and combined listings resolve the way they do in the built-in UI.

```js theme={null}
const product = swish.state.product({
  productId: item.productId,
  variantId: item.variantId,
});

swish.state.effect(() => {
  const { product: data, variant, loading } = product.value;
  if (loading || !data) return;
  console.log(data.title, variant?.image?.url ?? data.featuredImage?.url);
});
```

### `ProductIds`

| Property | Type | Required | Description |
| - | - | - | - |
| `productId` | `string \| number` | Yes | Shopify product ID or GID. |
| `variantId` | `string \| number \| null` | No | Shopify variant ID or GID. Resolves the saved variant when given. |

### State properties

| Property | Type | Description |
| - | - | - |
| `product` | `Product \| null` | The resolved product. `null` while loading or not found. |
| `variant` | `ProductVariant \| null` | The resolved variant, when `variantId` was given. |
| `loading` | `boolean` | `true` until the product has resolved or failed once. |
| `error` | `Error \| null` | Storefront error, if any. |

#### `product.dispose()`

Releases the cache subscription. Call it when the card unmounts.

## Declarative elements

For drop-in save buttons and drawer triggers, use the [`<swish-save>` and `<swish-drawer>` custom elements](https://swish.app/docs/references/sdk/elements). They wrap a merchant-provided button, bind the signals on this page to its markup, and reflect state via attributes you can target with CSS. The [examples](https://swish.app/docs/references/sdk/examples.md) page shows each element next to its state helper equivalent.

## Signal primitives

The SDK re-exports `signal`, `computed`, and `effect` from `@preact/signals-core` for building custom reactive logic.

### `signal(initialValue)`

Creates a reactive value. Reading `.value` inside an `effect` subscribes to changes.

```js theme={null}
const isOpen = swish.state.signal(false);

swish.state.effect(() => {
  console.log("open:", isOpen.value);
});

isOpen.value = true; // logs "open: true"
```

### `computed(fn)`

Creates a derived signal that recomputes when its dependencies change.

```js theme={null}
const context = swish.state.itemContext(element);
const state = swish.state.itemState(context);

const label = swish.state.computed(() => {
  return state.value.saved ? "Saved" : "Save";
});

console.log(label.value); // "Save" or "Saved"
```

### `effect(fn)`

Runs a side effect whenever any signal it reads changes. Returns a dispose function.

```js theme={null}
const dispose = swish.state.effect(() => {
  console.log(label.value); // runs now, and again whenever `label` changes
});

dispose(); // stops the logging
```

## Examples

The [SDK examples](https://swish.app/docs/references/sdk/examples.md) page shows these helpers in use - a save button, a header badge, a custom wishlist page, and a drawer with list tabs - each next to the custom element or React hook that does the same job.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.