Cart with Server & Client State
This example shows how to build a shopping cart that fetches products from an API, manages cart state across components, and uses middleware for logging.
Product data comes from the public Cami mock API at https://cami-api.exe.xyz.
Live islandRuns locally in this page
Page shell
<article>
<h2>Products</h2>
<product-list-component></product-list-component>
</article>
<article>
<h2>Cart</h2>
<cart-component></cart-component>
</article>
<script src="https://unpkg.com/cami@0.4/build/cami.cdn.js"></script>
<script type="module" src="./island.js"></script>
Island source
const { html, ReactiveElement, store } = cami
const AppStore = store({
name: 'AppStore',
state: {
cartItems: [],
products: { status: 'idle', data: [], error: null },
},
});
AppStore.defineAction('cart:add', ({ state, payload }) => {
const product = payload;
state.cartItems.push({ ...product, cartItemId: crypto.randomUUID() });
});
AppStore.defineAction('cart:remove', ({ state, payload }) => {
const { cartItemId } = payload;
state.cartItems = state.cartItems.filter((item) => item.cartItemId !== cartItemId);
});
AppStore.defineAction('products:setPending', ({ state }) => {
state.products.status = 'pending';
state.products.error = null;
});
AppStore.defineAction('products:setSuccess', ({ state, payload }) => {
state.products = { status: 'success', data: payload, error: null };
});
AppStore.defineAction('products:setError', ({ state, payload }) => {
state.products.status = 'error';
state.products.error = payload instanceof Error ? payload.message : String(payload);
});
AppStore.defineQuery('products:fetch', {
queryKey: ['products'],
queryFn: async () => {
const response = await fetch('https://cami-api.exe.xyz/products?_limit=3');
if (!response.ok)
throw new Error(`Products request failed: ${response.status}`);
return await response.json();
},
staleTime: 5 * 60_000,
onFetch: ({ dispatch }) => dispatch('products:setPending'),
onSuccess: ({ data, dispatch }) => dispatch('products:setSuccess', data),
onError: ({ error, dispatch }) => dispatch('products:setError', error),
});
AppStore.defineMemo('cart:total', ({ state }) => state.cartItems.reduce((total, item) => total + item.price, 0));
void AppStore.query('products:fetch');
class ProductListElement extends ReactiveElement {
isProductInCart(productId) {
return AppStore.getState().cartItems.some((item) => item.id === productId);
}
template() {
const { products } = AppStore.getState();
if (products.status === 'pending')
return html `<p>Loading…</p>`;
if (products.error)
return html `<p role="alert">${products.error}</p>`;
return html `<ul>${products.data.map((product) => html `
<li>
${product.name} — ${(product.price / 100).toFixed(2)}
<button
@click=${() => AppStore.dispatch('cart:add', product)}
?disabled=${product.stock === 0 || this.isProductInCart(product.id)}
>${this.isProductInCart(product.id) ? 'In Cart' : 'Add to cart'}</button>
</li>
`)}</ul>`;
}
}
class CartElement extends ReactiveElement {
template() {
const { cartItems } = AppStore.getState();
const total = AppStore.memo('cart:total');
if (cartItems.length === 0)
return html `<p>Cart is empty</p>`;
return html `
<p>Cart value: ${(total / 100).toFixed(2)}</p>
<ul>${cartItems.map((item) => html `
<li>${item.name} <button @click=${() => AppStore.dispatch('cart:remove', item)}>Remove</button></li>
`)}</ul>
`;
}
}
customElements.define('product-list-component', ProductListElement);
customElements.define('cart-component', CartElement);
import { html, ReactiveElement, store } from 'cami'
type RequestStatus = 'idle' | 'pending' | 'success' | 'error'
interface Product {
id: number
name: string
price: number
stock: number
}
interface CartItem extends Product {
cartItemId: string
}
interface AppState {
cartItems: CartItem[]
products: { status: RequestStatus; data: Product[]; error: string | null }
}
const AppStore = store<AppState>({
name: 'AppStore',
state: {
cartItems: [],
products: { status: 'idle', data: [], error: null },
},
})
AppStore.defineAction('cart:add', ({ state, payload }) => {
const product = payload as Product
state.cartItems.push({ ...product, cartItemId: crypto.randomUUID() })
})
AppStore.defineAction('cart:remove', ({ state, payload }) => {
const { cartItemId } = payload as CartItem
state.cartItems = state.cartItems.filter((item: CartItem) => item.cartItemId !== cartItemId)
})
AppStore.defineAction('products:setPending', ({ state }) => {
state.products.status = 'pending'
state.products.error = null
})
AppStore.defineAction('products:setSuccess', ({ state, payload }) => {
state.products = { status: 'success', data: payload as Product[], error: null }
})
AppStore.defineAction('products:setError', ({ state, payload }) => {
state.products.status = 'error'
state.products.error = payload instanceof Error ? payload.message : String(payload)
})
AppStore.defineQuery<void, Product[]>('products:fetch', {
queryKey: ['products'],
queryFn: async (): Promise<Product[]> => {
const response = await fetch('https://cami-api.exe.xyz/products?_limit=3')
if (!response.ok) throw new Error(`Products request failed: ${response.status}`)
return await response.json() as Product[]
},
staleTime: 5 * 60_000,
onFetch: ({ dispatch }) => dispatch('products:setPending'),
onSuccess: ({ data, dispatch }) => dispatch('products:setSuccess', data),
onError: ({ error, dispatch }) => dispatch('products:setError', error),
})
AppStore.defineMemo<number>('cart:total', ({ state }) =>
state.cartItems.reduce((total: number, item: CartItem) => total + item.price, 0),
)
void AppStore.query<Product[]>('products:fetch')
class ProductListElement extends ReactiveElement {
isProductInCart(productId: number): boolean {
return AppStore.getState().cartItems.some((item: CartItem) => item.id === productId)
}
template(): ReturnType<typeof html> {
const { products } = AppStore.getState()
if (products.status === 'pending') return html`<p>Loading…</p>`
if (products.error) return html`<p role="alert">${products.error}</p>`
return html`<ul>${products.data.map((product: Product) => html`
<li>
${product.name} — ${(product.price / 100).toFixed(2)}
<button
@click=${() => AppStore.dispatch('cart:add', product)}
?disabled=${product.stock === 0 || this.isProductInCart(product.id)}
>${this.isProductInCart(product.id) ? 'In Cart' : 'Add to cart'}</button>
</li>
`)}</ul>`
}
}
class CartElement extends ReactiveElement {
template(): ReturnType<typeof html> {
const { cartItems } = AppStore.getState()
const total = AppStore.memo('cart:total') as number
if (cartItems.length === 0) return html`<p>Cart is empty</p>`
return html`
<p>Cart value: ${(total / 100).toFixed(2)}</p>
<ul>${cartItems.map((item: CartItem) => html`
<li>${item.name} <button @click=${() => AppStore.dispatch('cart:remove', item)}>Remove</button></li>
`)}</ul>
`
}
}
declare global {
interface HTMLElementTagNameMap {
'product-list-component': ProductListElement
'cart-component': CartElement
}
}
customElements.define('product-list-component', ProductListElement)
customElements.define('cart-component', CartElement)