htmx cart island
A cart island inside a page that htmx owns. htmx swaps the server’s HTML into the page; kerf then mounts onto the swapped shell and owns its interactivity until the next swap replaces it. The runnable app simulates the swap with Load cart and Reload cart buttons, so it works against a static server with no backend. The htmx migration guide walks through the production shape.
What to look at:
- Mount after the swap. In a real htmx app the mount runs in an
htmx:afterSwaphandler. kerf reads the island’s initial items from itsdata-initialattribute, hydrates a signal, andmount()s the render onto the swapped shell. - Initial data set through
dataset. The simulated swap inserts static markup and then setsdata-initialthroughdataset, rather than interpolating JSON into the HTML string. A quote in a product name can’t break out of the attribute. - Capture both disposers. The shell is transient: every swap replaces it. The app keeps the disposers from
mount()anddelegate()and calls both before the next mount, so each swap leaves no listener or effect behind. The event log on the page shows the dispose-then-mount sequence. - A keyed
each()for the rows. Removing an item writes the signal; the keyed reconciler removes one row and the total updates in place. attr()keeps the attribute and selector in one place. The remove button spreadsACTIONS.remove.attrs, and the delegated listener matchesACTIONS.remove.selector.
// site/src/examples/complete/cart-htmx/main.tsx (excerpt — full source on GitHub)import { attr, delegate, each, mount, signal, type AttrSpec } from "kerfjs";
const ACTIONS = { remove: attr("data-action", "remove"),} as const satisfies Record<string, AttrSpec<"data-action">>;
let activeDispose: (() => void) | null = null;
function simulateSwap(initial: CartItem[]): void { activeDispose?.(); // tear down the previous island before the new one mounts
shell.innerHTML = `<div data-cart-island>…static shell…</div>`; const root = shell.querySelector("[data-cart-island]") as HTMLElement; root.dataset.initial = JSON.stringify(initial);
// Real htmx: this runs in an `htmx:afterSwap` handler. const items = signal<CartItem[]>(JSON.parse(root.dataset.initial!)); const stopMount = mount(root, () => ( <div data-cart-island> <ul class="cart-items"> {each( items.value, (it) => ( <li data-key={it.id}> {it.name} — ${it.price} <button {...ACTIONS.remove.attrs} data-id={it.id}>×</button> </li> ), (it) => it.id, )} </ul> <p class="total"> Total: ${items.value.reduce((s, it) => s + it.price, 0)} </p> </div> ));
// Transient root: capture the disposer and call it on the next swap. const stopDelegate = delegate(root, "click", ACTIONS.remove.selector, (_e, btn) => { const id = (btn as HTMLElement).dataset.id; items.value = items.value.filter((it) => it.id !== id); });
activeDispose = () => { stopMount(); stopDelegate(); };}