Heart icon, optimistic UI and guest merge
A wishlist toggle button has to feel instant, regardless of how long the server takes to respond. With Alpine state, optimistic UI and a global store, a heart icon can be built that stays in sync across any number of product cards and offers a working wishlist even to visitors who are not logged in.
Table of contents
- 1. Why a wishlist toggle button must react instantly
- 2. Foundation: state per product card
- 3. Optimistic UI: toggle first, sync afterwards
- 4. Backend integration with GraphQL mutations
- 5. Global state across every product card
- 6. Heart icon animation without an external library
- 7. Guests: localStorage fallback and merge after login
- 8. Accessibility: aria-pressed and status announcements
- 9. Wishlist implementations compared
- 10. Summary
- 11. FAQ
1. Why a wishlist toggle button must react instantly
A wishlist toggle button is usually a small heart icon on every product card that switches between two states: saved or not saved. It sounds trivial, but it is one of the interactions where customers notice delays especially clearly, because the click does not trigger page navigation and therefore an immediate visual response is expected. A wishlist toggle button that only reacts after a server round trip feels sluggish, even if the response time is objectively only 200 milliseconds.
In Hyvä themes this problem can be elegantly solved with Alpine state and the optimistic UI principle: the wishlist toggle button changes its visual state immediately on click, while the actual request to the server runs in the background. If the request fails, the state is reverted and the customer is informed. This pattern feels instantaneous to the customer without compromising data integrity.
The second central point is that a product is often visible on several cards at the same time, for example in the product listing and in a cross-selling slider. A good wishlist toggle button keeps the state synchronized across all these cards, without each card managing its own isolated copy of the state. The following sections build exactly this component, from the single card to the global state and accessibility.
2. Foundation: state per product card
Every product card gets its own x-data component holding the product ID and the current wishlist status. The initial status is passed server side through a data attribute from the phtml template into the component, so no extra request is needed on first render. This prevents a brief flash of the wrong state that would occur if the status were only loaded via fetch after the page load.
The wishlist toggle button itself is deliberately defined as a standalone function called per card, but internally accesses a global store as soon as cross-product synchronization is involved. This separation between local UI state and global data state is the core of the entire component.
// Per-card wishlist toggle component
function wishlistToggle(productId, initiallySaved) {
return {
productId,
isSaved: initiallySaved,
isPending: false,
get isInWishlist() {
// Fall back to the global store once it has synced
return Alpine.store('wishlist').ids.has(this.productId) || this.isSaved;
},
async toggle() {
const wasSaved = this.isInWishlist;
this.isSaved = !wasSaved; // optimistic flip, see section 3
this.isPending = true;
try {
await Alpine.store('wishlist').toggleProduct(this.productId, wasSaved);
} catch (error) {
this.isSaved = wasSaved; // revert on failure
} finally {
this.isPending = false;
}
}
};
}
The isInWishlist getter combines local and global state: as long as the global store has not yet initialized, the card falls back to its own initial value. Once the store has loaded, it takes over. This transition is invisible to the customer and prevents a brief jump in the wishlist toggle button during page load.
3. Optimistic UI: toggle first, sync afterwards
Optimistic UI means the interface acts as if the action already succeeded, before the server response even arrives. For a wishlist toggle button this is almost always the right choice, because the error rate for a simple wishlist addition is very low and a rollback in the rare failure case is unobtrusive.
The art lies in clearly separating the optimistic state from the actually confirmed state, so that on failure it can be jumped back exactly to where the customer stood before the click. An isPending flag optionally shows a subtle loading indicator without changing the actual visibility of the icon.
<button
x-data="wishlistToggle(42, false)"
@click="toggle()"
:disabled="isPending"
:aria-pressed="isInWishlist"
class="relative w-9 h-9 flex items-center justify-center rounded-full hover:bg-slate-100 transition-colors"
>
<span class="sr-only" x-text="isInWishlist ? 'Remove from wishlist' : 'Add to wishlist'"></span>
<svg
class="w-5 h-5 transition-transform duration-150"
:class="isInWishlist ? 'text-rose-500 scale-110' : 'text-slate-400 scale-100'"
:fill="isInWishlist ? 'currentColor' : 'none'"
stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4.318 6.318a4.5 4.5 0 010 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z">
</path>
</svg>
</button>
Scaling the icon with scale-110 gives the wishlist toggle button a small tactile effect without needing a complex animation. Changing the fill attribute between none and currentColor produces the classic switch from an outline heart to a filled one, familiar from almost every e-commerce application.
4. Backend integration with GraphQL mutations
Magento provides the GraphQL mutations addProductsToWishlist and removeProductsFromWishlist for the wishlist. Both expect a wishlist ID and an array of product IDs or wishlist item IDs. For a simple wishlist toggle button it is enough to use the customer's default wishlist, retrieved through the query customer { wishlist { id } }.
It is important that both mutations only work for logged in customers, since the wishlist is tied to the customer account. For guests, the wishlist toggle button therefore needs a separate mechanism, covered in section 7.
async function toggleWishlistOnServer(productId, isCurrentlySaved) {
const mutation = isCurrentlySaved
? `mutation Remove($wishlistId: ID!, $itemId: ID!) {
removeProductsFromWishlist(wishlistId: $wishlistId, wishlistItemsIds: [$itemId]) {
wishlist { items_count }
user_errors { message }
}
}`
: `mutation Add($wishlistId: ID!, $productId: Int!) {
addProductsToWishlist(wishlistId: $wishlistId, wishlistItems: [{ sku: null, quantity: 1, entered_options: [] }]) {
wishlist { items_count }
user_errors { message }
}
}`;
const response = await fetch('/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${window.customerToken}` },
body: JSON.stringify({ query: mutation, variables: { wishlistId: window.wishlistId, itemId: productId, productId } })
});
const { data, errors } = await response.json();
if (errors || data?.addProductsToWishlist?.user_errors?.length) {
throw new Error('Wishlist mutation failed');
}
return data;
}
The failure case is deliberately signalled through a thrown error, so the calling component from section 3 can revert the optimistic state. This clear error handling is crucial so the wishlist toggle button never shows a state that was not actually persisted in the backend.
5. Global state across every product card
For the same wishlist toggle button to react correctly on several cards at once, an Alpine.store('wishlist') is kept with a Set of product IDs. A Set is the right data structure here, because membership checks with has() run in constant time, regardless of how many items are on the wishlist.
When toggling an item, the store updates the Set centrally, so every card checking the same product ID value automatically sees the new state, even if it sits at a completely different position in the DOM.
document.addEventListener('alpine:init', () => {
Alpine.store('wishlist', {
ids: new Set(window.initialWishlistIds || []),
async toggleProduct(productId, wasSaved) {
// Update local set immediately, then confirm with the server
if (wasSaved) {
this.ids.delete(productId);
} else {
this.ids.add(productId);
}
try {
await toggleWishlistOnServer(productId, wasSaved);
} catch (error) {
// Revert the set on failure so every card reflects the true state
if (wasSaved) { this.ids.add(productId); } else { this.ids.delete(productId); }
throw error;
}
}
});
});
Since Alpine does not automatically observe Set objects reactively through x-text expressions, every card must query the store through a getter, as shown in section 2, instead of binding to the Set directly. That keeps the wishlist toggle button reactive on every card, without Alpine needing deeper proxy mechanisms for complex data structures.
6. Heart icon animation without an external library
A small but effective addition to the wishlist toggle button is a short pulse effect when adding to the wishlist. Instead of an external animation library, a combination of Tailwind transition classes and a briefly set x-data flag that automatically resets after a few hundred milliseconds is enough.
This approach avoids additional dependencies and stays fully declarative within the Alpine component. The effect feels subtle but noticeably reinforces the perceived responsiveness of the wishlist toggle button.
function wishlistToggleWithPulse(productId, initiallySaved) {
return {
productId,
isSaved: initiallySaved,
isPulsing: false,
async toggle() {
const wasSaved = this.isSaved;
this.isSaved = !wasSaved;
if (!wasSaved) {
this.isPulsing = true;
setTimeout(() => { this.isPulsing = false; }, 300);
}
await Alpine.store('wishlist').toggleProduct(productId, wasSaved);
}
};
}
In the markup, a conditional class like :class="isPulsing ? 'scale-125' : 'scale-100'" combined with transition-transform is enough to make the pulse visible. It is important to match the timeout duration exactly to the CSS transition duration, so the wishlist toggle button does not jump back mid animation.
7. Guests: localStorage fallback and merge after login
Since the GraphQL wishlist mutations require a customer account, a good wishlist toggle button needs a fallback for guests. The pragmatic solution is to store product IDs in localStorage while no customer is logged in. The wishlist toggle button behaves visually identical for the guest, only the data lives locally in the browser instead of in the backend.
After a successful login, the local list is merged once with the server side wishlist, through a sequential call of the addProductsToWishlist mutation for every locally stored product ID. Afterwards the localStorage entry is deleted so no stale data remains.
Alpine.store('wishlist').toggleProductGuest = function (productId, wasSaved) {
const stored = new Set(JSON.parse(localStorage.getItem('guest-wishlist') || '[]'));
if (wasSaved) { stored.delete(productId); } else { stored.add(productId); }
localStorage.setItem('guest-wishlist', JSON.stringify([...stored]));
this.ids = stored;
};
// Called once, right after a successful login
async function mergeGuestWishlistAfterLogin() {
const guestIds = JSON.parse(localStorage.getItem('guest-wishlist') || '[]');
for (const productId of guestIds) {
await toggleWishlistOnServer(productId, false);
}
localStorage.removeItem('guest-wishlist');
}
This sequential processing with for...of instead of Promise.all is a deliberate choice to avoid rate limiting on the GraphQL interface that some Magento installations trigger with parallel bulk requests. For the typical case of a handful of saved items, the extra latency from sequential processing is negligible.
8. Accessibility: aria-pressed and status announcements
A wishlist toggle button is semantically a toggle button and should therefore always use aria-pressed instead of aria-checked. The value must be dynamically bound to the current state, so screen readers correctly announce whether the item is already saved. Additionally, the visible text in the sr-only span should change with the state, as shown in section 3, so the call to action always matches the current state.
For complete accessibility it is also worth adding a short toast notification after toggling, announced through an aria-live region. This way screen reader users also learn that the action succeeded, without having to move focus back to the wishlist toggle button manually.
<div class="sr-only" role="status" aria-live="polite" x-text="wishlistStatusMessage"></div>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('wishlistStatus', () => ({
wishlistStatusMessage: '',
announce(saved) {
this.wishlistStatusMessage = saved
? 'Item added to your wishlist'
: 'Item removed from your wishlist';
}
}));
});
</script>
This message should be set briefly after every successful toggle and cleared again after one or two seconds, so it is announced again on a repeated click of the same wishlist toggle button. Without this reset, some screen readers ignore an identical, unchanged text change.
9. Wishlist implementations compared
There are different maturity levels for implementing a wishlist toggle button, from a simple form submission to a full optimistic UI solution with guest support.
| Aspect | Simple implementation | Recommended wishlist pattern | Benefit |
|---|---|---|---|
| Reaction to click | Waiting for server response | Optimistic UI with rollback | Feels instantaneous |
| State across cards | Every card loads its own status | Alpine.store() with a Set | Synchronized, one request instead of many |
| Guests | Button hidden for guests | localStorage fallback + merge | Works without login, no data loss |
| Toggle semantics | Only a visual class, no ARIA | aria-pressed + aria-live |
Fully usable for screen readers |
| Error handling | Silent failure without rollback | Revert state on failure | UI never shows an incorrect state |
The jump from the simple to the recommended implementation barely requires more code, but noticeably more care in state management. Whoever plans the wishlist toggle button from the start with optimistic UI and a global store saves later refactoring once the product appears on several pages at once.
Mironsoft
Hyvä theme development and Alpine.js components for Magento
A wishlist toggle button that stays truly in sync?
We build wishlist and other interaction patterns as reactive Alpine.js components with optimistic UI, GraphQL integration and guest support.
Wishlist
Optimistic UI, synchronized state across every product card
Guest support
localStorage fallback with a clean merge after login
Accessibility
aria-pressed, live regions and full keyboard operability
10. Summary
A well built wishlist toggle button combines four building blocks: optimistic UI for immediate visual feedback, a global Alpine.store() with a Set for cross-product synchronization, clean GraphQL integration with clear error handling, and a localStorage fallback for guests with a subsequent merge after login. Every one of these building blocks can be tested and extended independently, without affecting the others.
Accessibility for a wishlist toggle button is not an afterthought, it is part of the core structure: aria-pressed, a dynamic sr-only text and an aria-live region for status announcements cost little effort but make the button equally usable for every customer. Once this component is built cleanly, it can be reused unchanged on product listings, cross-selling sliders and product detail pages.
Wishlist Toggle Button with Alpine State — The essentials at a glance
Optimistic UI
Flip the state immediately, revert on server failure. Feels instantaneous to the customer.
Global store
Alpine.store('wishlist') with a Set keeps every product card synchronized.
Guests
localStorage fallback, sequential merge with the server wishlist after login.
Accessibility
aria-pressed, dynamic sr-only text and an aria-live status announcement.