#68885·woocommerce

Proposal: a unified `woocommerce` Interactivity API store

Author: SantosGuillamotCreated Sep 18, 2026Updated Sep 18, 2026
LabelsBlocks/Patterns/Templates

Today the product data and the cart live in separate Interactivity API stores. This proposal merges them into a single woocommerce store: one place to read the catalog, one read-only mirror of the cart, and one client-only layer for the item a shopper is configuring.

The proposal uses four terms throughout. They are explained below in more detail, but this is a short summary:

  • A product scope is one product together with its data, its draft, and its cart item. Markup declares the scope through a woocommerce context (see the context).
  • Its record is the entry in state.productScopes under the scope's scopeName. The record is what the shopper has changed.
  • The draft cart item is what the record holds: the item as it would be posted to the cart (id, variation, quantity and any extension props).
  • The envelope, state.productScope, joins the product, the cart item, and the draft cart item for the product scope the surrounding markup declares.

Demo

I created a small app that runs the store for real, so the API can be tried rather than only read: store/woocommerce.js is the store itself, and pages/ holds the pages the use cases below walk through.

StackBlitz

This is not final code; its goal is only to make the demo run on its own. Every link navigates client-side, which is what lets drafts survive moving between pages. Each page has a Show product scopes toggle at the bottom that prints state.productScopes live, so you can watch records appear, change, and disappear as you pick options, set quantities, and add to the cart.

The store API

This is the shape of the API in this proposal

State

javascript
// state
{
	products,          // the products from the Store API
	productVariations, // the product variations from the Store API
	cart,              // the cart from the Store API
	productScopes,     // every product scope's record, by scope name; in memory, kept across client-side navigation
	productScope,      // the envelope for the product scope the surrounding markup declares
	findProductScope( ref ), // the envelope for a product the surrounding markup does not declare, e.g. a grouped child from its parent's button
	template: {        // the product the template is rendering
		productId,
		variation,
	},
}

The envelope, each productScope, contains these props:

javascript
{
	scopeName,        // the key its record sits at in state.productScopes — read-only
	productId,        // the product this scope addresses — writable
	variation,        // the selected attributes, [ { attribute, value } ] — writable
	draftCartItem: {  // the item as it would be posted — writable
		id,            // the same fact as productId above, in the form cart/add-item expects
		variation,     // the same fact as variation above
		quantity,      // what the shopper typed; 1 until then// extension props, exactly as cart/add-item accepts them, e.g. 'wc-gift-demo/message'
	},
	baseProduct,      // the product's catalog entry; for a variable product, the parent — read-only
	productVariation, // the variation matching the selected attributes, if any — read-only
	product,          // the variation when there is one, else the base product — read-only
	cartItem,         // the paired cart line — read-only
}

A findProductScope ref has the same shape as a product scope's context (productId, variation, scopeName, cartItemKey). One of productId or cartItemKey is required; the rest are optional. The product data is matched using the id and variation. Without a scopeName, the draft is matched by identity the same way the cart line is: the one record whose id equals the ref's productId (and whose variation equals the ref's, when given).

Actions

javascript
// actions
{
	addCartItem( payload? ),             // no payload: posts state.productScope.draftCartItem and clears its record; a payload is posted as-is and nothing is cleared
	updateCartItem( { key, quantity } ), // change a cart line's quantity
	removeCartItem( key ),               // remove a cart line
	refreshCart(),                       // re-read the cart from the server
}

The context implementers define

Prop Meaning
productId Which product this product scope addresses.
variation The selected attributes for this product scope.
scopeName This scope's name: the key its record sits at in state.productScopes. Any string.
cartItemKey Pairs this product scope with one specific cart line.

Each block or implementation can add its own context on top.

Why a scopeName and productScopes

Client-side navigation is the main reason. When the shopper navigates away and back, the page re-renders and every product scope remounts with the context declared in its markup, so anything written to the context is lost. state.productScopes survives the round trip, and a scope's name is how it finds its own record again.

Product scopes with the same scopeName share the same draftCartItem in state.productScopes, and a product scope must produce the same name every time the page renders.

Implementers can write any scopeName. We can provide a PHP helper that generates one from the URL and/or a form key. This will be investigated during implementation. It would provide four behaviours:

  1. URL + form key. The draft belongs to this form on this page.
  2. Form key only. The draft follows the form across pages.
  3. URL only. Every product scope on the page has the same scopeName, so they share the same draftCartItem.
  4. Nothing (no scopeName). Every product scope on every page has the shared _default name, so they all share the same draftCartItem.

Reading and writing

Product, cart, and draft data are read from the one envelope, straight from markup:

xml
<!-- product data -->
<span data-wp-text="state.productScope.product.name"></span>

<!-- cart data -->
<span data-wp-text="state.productScope.cartItem.quantity"></span>

<!-- draft data: what the shopper is configuring -->
<input
	type="number"
	data-wp-bind--value="state.productScope.draftCartItem.quantity"
/>

<!-- identity: the same name whether or not anything is drafted -->
<select data-wp-bind--value="state.productScope.productId"></select>

The envelope's productId, variation and draftCartItem read from the record first, then from the declared context, or from state.template for a scope that declares no productId. baseProduct, productVariation, product and cartItem derive from those.

Writes go two ways. Identity writes (productId and variation) update both the declared context and the record; every other write goes under draftCartItem and updates the record only. The first write to a scope creates its record.

javascript
// 1. The identity. Writes context AND the record.
state.productScope.variation = [
	{ attribute: 'Color', value: 'Blue' },
	{ attribute: 'Size', value: 'Medium' },
];
state.productScope.productId = 100;

// The same two facts under the payload's names. Same setter, same result.
state.productScope.draftCartItem.variation = [ … ];
state.productScope.draftCartItem.id = 100;

// 2. Everything else on the item. Writes the record only.
state.productScope.draftCartItem.quantity = 2;

Extension props

The draft cart item carries extension props exactly as the Store API's cart/add-item endpoint accepts them:

javascript
state.productScope.draftCartItem[ 'wc-gift-demo/message' ] = 'Happy birthday!';

Use cases

Snippets are trimmed to the parts that touch the store; the full pages are in pages/. Scope names are illustrative.

1. Variable product

One product form: pick a variation, set a quantity, add it to the cart.

Open this page in StackBlitz

xml
<div data-wp-context='{ "productId": 100, "scopeName": "t-shirt-form" }'>
	<h2 data-wp-text="state.productScope.product.name"></h2>

	<select
		data-wp-context='{ "attributeName": "Color" }'
		data-wp-bind--value="wcDemo::state.selectedAttribute"
		data-wp-on--change="wcDemo::actions.selectAttribute"
	></select>

	<input
		type="number"
		data-wp-bind--value="state.productScope.draftCartItem.quantity"
		data-wp-on--change="wcDemo::actions.setQuantity"
	/>

	<button
		data-wp-on--click="actions.addCartItem"
		data-wp-bind--disabled="!state.productScope.productVariation"
	>
		Add to cart
	</button>
</div>

Both handlers are a single write to the envelope:

javascript
// wcDemo::actions.selectAttribute
state.productScope.variation = [
	{ attribute: 'Color', value: 'Blue' },
	{ attribute: 'Size', value: 'Medium' },
];

// wcDemo::actions.setQuantity
state.productScope.draftCartItem.quantity = Number( event.target.value );

Pick Blue / Medium and type 2, and the record sits at the scope name:

json
{
	"t-shirt-form": {
		"draftCartItem": {
			"id": 100,
			"variation": [
				{ "attribute": "Color", "value": "Blue" },
				{ "attribute": "Size", "value": "Medium" }
			],
			"quantity": 2
		}
	}
}

addCartItem() posts that draft cart item and removes the record. Navigate away and back without adding, and the selects come back filled in, with no restore code on the page.

2. Grouped product

One grouped product with several children: set quantities per child, then add them with one button. Each child row is its own product scope with its own scopeName.

Open this page in StackBlitz

xml
<div
	data-wp-context='{ "productId": 31, "scopeName": "logo-collection", "groupedScopeNames": [ "logo-collection:11", "logo-collection:12" ] }'
>
	<div
		data-wp-context='{ "productId": 11, "scopeName": "logo-collection:11" }'
	>
		<input
			type="number"
			data-wp-bind--value="wcDemo::state.groupedChildQuantity"
			data-wp-on--change="wcDemo::actions.setQuantity"
		/>
		<button
			data-wp-on--click="actions.addCartItem"
			data-wp-bind--disabled="wcDemo::state.groupedChildAddDisabled"
		>
			Add to cart
		</button>
	</div>
	<!-- one row per child, each with its own scopeName -->

	<button data-wp-on--click="wcDemo::actions.addGroupedItems">
		Add to cart
	</button>
</div>

groupedScopeNames is the page's own context, not the store's: the group's button is the one thing that reads it, walking its rows' records in state.productScopes and calling addCartItem with each row's draftCartItem. Every row uses the same setQuantity handler as the single form:

javascript
state.productScope.draftCartItem.quantity = Number( event.target.value );

Because each row is its own product scope, that identical write lands on that row's own record. Set both rows:

json
{
	"logo-collection:11": { "draftCartItem": { "id": 11, "quantity": 2 } },
	"logo-collection:12": { "draftCartItem": { "id": 12, "quantity": 1 } }
}

The group's button walks groupedScopeNames, calls addCartItem( state.productScopes[ name ].draftCartItem ) for each drafted row.

3. Shared child

Two grouped products sharing the same child product, each keeping its own quantities.

Open this page in StackBlitz

xml
<div
	data-wp-context='{ "productId": 31, "scopeName": "logo-collection", "groupedScopeNames": [ "logo-collection:11", … ] }'
>
	<div
		data-wp-context='{ "productId": 11, "scopeName": "logo-collection:11" }'
	></div>
	<button data-wp-on--click="wcDemo::actions.addGroupedItems">
		Add to cart
	</button>
</div>

<div
	data-wp-context='{ "productId": 32, "scopeName": "travel-collection", "groupedScopeNames": [ "travel-collection:11", … ] }'
>
	<div
		data-wp-context='{ "productId": 11, "scopeName": "travel-collection:11" }'
	></div>
	<button data-wp-on--click="wcDemo::actions.addGroupedItems">
		Add to cart
	</button>
</div>

Same product, two records, because each parent's rows declare a different scope name:

json
{
	"logo-collection:11": { "draftCartItem": { "id": 11, "quantity": 2 } },
	"travel-collection:11": { "draftCartItem": { "id": 11, "quantity": 1 } }
}

The page also has a small "Beanie in cart" widget outside any product scope. It reads state.findProductScope( { productId: 11 } ).cartItem. The ref matches both Beanie records, so it gets no draft, but cartItem still pairs by product id, which is all the widget reads.

4. Two variations of one product

Two independent forms for the same product, each pre-set to a different variation. Distinct scopeNames keep them apart, and both selections survive navigating away and back.

Open this page in StackBlitz

xml
<div
	data-wp-context='{ "productId": 100, "scopeName": "t-shirt-blue", "variation": [ { "attribute": "Color", "value": "Blue" }, { "attribute": "Size", "value": "Small" } ] }'
></div>

<div
	data-wp-context='{ "productId": 100, "scopeName": "t-shirt-green", "variation": [ { "attribute": "Color", "value": "Green" }, { "attribute": "Size", "value": "Small" } ] }'
></div>

Change both to Medium and the two drafts stay separate:

json
{
	"t-shirt-blue": {
		"draftCartItem": {
			"id": 100,
			"variation": [
				{ "attribute": "Color", "value": "Blue" },
				{ "attribute": "Size", "value": "Medium" }
			]
		}
	},
	"t-shirt-green": {
		"draftCartItem": {
			"id": 100,
			"variation": [
				{ "attribute": "Color", "value": "Green" },
				{ "attribute": "Size", "value": "Medium" }
			]
		}
	}
}

5. Sticky bar

One product edited from two synced surfaces: a full form at the top of the page and a compact bar pinned to the bottom. The page seeds state.template.productId, as the Single Product Template would. Neither surface names a product, so both resolve the template's, and both declare the same scopeName, so a change in one shows up in the other.

Open this page in StackBlitz

xml
<section data-wp-context='{ "scopeName": "t-shirt-page" }'>
	<select
		data-wp-context='{ "attributeName": "Color" }'
		data-wp-bind--value="wcDemo::state.selectedAttribute"
		data-wp-on--change="wcDemo::actions.selectAttribute"
	></select>

	<input
		type="number"
		data-wp-bind--value="state.productScope.draftCartItem.quantity"
		data-wp-on--change="wcDemo::actions.setQuantity"
	/>

	<button data-wp-on--click="actions.addCartItem">Add to cart</button>
</section>

<!-- … static page content, tall enough that the form above scrolls out of view … -->

<div class="sticky-bar" data-wp-context='{ "scopeName": "t-shirt-page" }'>
	<input
		type="number"
		data-wp-bind--value="state.productScope.draftCartItem.quantity"
		data-wp-on--change="wcDemo::actions.setQuantity"
	/>

	<button data-wp-on--click="actions.addCartItem">Add to cart</button>
</div>

Pick Blue / Small from the form, then a quantity from the bar, and both read the one shared record:

json
{
	"t-shirt-page": {
		"draftCartItem": {
			"id": 100,
			"variation": [
				{ "attribute": "Color", "value": "Blue" },
				{ "attribute": "Size", "value": "Small" }
			],
			"quantity": 3
		}
	}
}

6. Bundle

An extension composing the store: a two-slot bundle, configured per slot and added to the cart as one unit. The implementation could be different; this is one way it could work.

Open this page in StackBlitz

xml
<div
	data-wp-context='{ "productId": 40, "scopeName": "bundle", "slotProducts": { "bundle:slot-1": 100, "bundle:slot-2": 11 } }'
>
	<!-- slot 1: an ordinary product scope with its own productId and scopeName -->
	<div data-wp-context='{ "productId": 100, "scopeName": "bundle:slot-1" }'>
		<select
			data-wp-context='{ "attributeName": "Color" }'
			data-wp-bind--value="wcDemo::state.selectedAttribute"
			data-wp-on--change="wcDemo::actions.selectAttribute"
		></select>
		<input
			type="number"
			data-wp-bind--value="state.productScope.draftCartItem.quantity"
			data-wp-on--change="wcDemo::actions.setQuantity"
		/>
	</div>
	<!-- slot 2, the simple child, the same shape minus the attribute select -->

	<button data-wp-on--click="wc-bundle-demo::actions.addBundle">
		Add to cart
	</button>
</div>

Each slot is a product scope like any other, reading and writing only its own record. slotProducts is the extension's own context, listing the slots. Pick Blue / Medium on slot 1:

json
{
	"bundle:slot-1": {
		"draftCartItem": {
			"id": 100,
			"variation": [
				{ "attribute": "Color", "value": "Blue" },
				{ "attribute": "Size", "value": "Medium" }
			]
		}
	}
}

The page gates the add until every slot's product resolves. At add time the extension builds one payload — the bundle product with each slot's stored draftCartItem under 'wc-bundle-demo/children' — posts it with addCartItem( payload ) and deletes the slots' records.

7. Minicart

All cart items with editable quantities and remove controls. The list iterates state.cart.items; every action replaces it with the server's response, and the list re-renders.

Open this page in StackBlitz

xml
<div data-wp-init="actions.refreshCart">
	<template
		data-wp-each--cart-item="state.cart.items"
		data-wp-each-key="context.cartItem.key"
	>
		<div>
			<span data-wp-text="wcDemo::state.lineName"></span>
			<input
				type="number"
				data-wp-bind--value="context.cartItem.quantity"
				data-wp-on--change="wcDemo::actions.onCartLineQuantity"
			/>
			<button data-wp-on--click="wcDemo::actions.onCartLineRemove">
				Remove
			</button>
		</div>
	</template>
</div>

The two handlers are one call each:

javascript
actions.updateCartItem( { key: cartItem.key, quantity } );
actions.removeCartItem( cartItem.key );

No draft is involved: a cart line is server state, addressed by its own key.

8. Collection across two pages

A two-page collection where each product tile has its own quantity. Quantities typed on page 1 are still there after paging to page 2 and back, because each tile declares its own scopeName.

Open this page in StackBlitz

xml
<div data-wp-context='{ "productId": 11, "scopeName": "collection:11" }'>
	<input
		type="number"
		data-wp-bind--value="state.productScope.draftCartItem.quantity"
		data-wp-on--change="wcDemo::actions.setQuantity"
	/>
	<button data-wp-on--click="actions.addCartItem">Add to cart</button>
</div>

<a href="/pages/collection-2.html" data-wp-on--click="wcDemo::actions.navigate"
	>Next page</a
>

Typing on both pages adds records to the one flat map:

json
{
	"collection:11": { "draftCartItem": { "id": 11, "quantity": 3 } },
	"collection:14": { "draftCartItem": { "id": 14, "quantity": 2 } }
}

A full reload clears all of it.

9. Gift message

A second extension: an optional gift message attached to a product. It writes one extension prop onto the same draft cart item the quantity input writes to.

Open this page in StackBlitz

xml
<div data-wp-context='{ "productId": 11, "scopeName": "gift-form" }'>
	<input
		type="number"
		data-wp-bind--value="state.productScope.draftCartItem.quantity"
		data-wp-on--change="wcDemo::actions.setQuantity"
	/>
	<input
		type="text"
		data-wp-bind--value=