Herz-Icon, Optimistic UI und Gastnutzer-Merge
Ein Wishlist Toggle Button muss sich sofort anfühlen, unabhängig davon, wie lange der Server für die Antwort braucht. Mit Alpine State, Optimistic UI und einem globalen Store lässt sich ein Herz-Icon bauen, das über beliebig viele Produktkarten synchron bleibt und auch nicht angemeldeten Besuchern eine funktionierende Merkliste bietet.
Inhaltsverzeichnis
- 1. Warum ein Wishlist Toggle Button sofort reagieren muss
- 2. Grundgerüst: State pro Produktkarte
- 3. Optimistic UI: sofort umschalten, dann synchronisieren
- 4. Backend-Anbindung mit GraphQL-Mutationen
- 5. Globaler State über alle Produktkarten hinweg
- 6. Herz-Icon-Animation ohne externe Bibliothek
- 7. Gastnutzer: localStorage-Fallback und Merge nach Login
- 8. Barrierefreiheit: aria-pressed und Statusmeldungen
- 9. Wishlist-Implementierungen im Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Warum ein Wishlist Toggle Button sofort reagieren muss
Ein Wishlist Toggle Button ist meist ein kleines Herz-Icon auf jeder Produktkarte, das zwischen zwei Zuständen wechselt: gemerkt oder nicht gemerkt. Klingt trivial, ist aber eine der Interaktionen, bei denen Kunden Verzögerungen besonders deutlich wahrnehmen, weil der Klick keine Seitennavigation auslöst und daher eine sofortige visuelle Rückmeldung erwarten. Ein Wishlist Toggle Button, der erst nach einem Server-Roundtrip reagiert, wirkt träge, selbst wenn die Antwortzeit objektiv nur 200 Millisekunden beträgt.
In Hyvä-Themes lässt sich dieses Problem mit Alpine State und dem Prinzip Optimistic UI elegant lösen: Der Wishlist Toggle Button ändert seinen visuellen Zustand sofort beim Klick, der eigentliche Request an den Server läuft im Hintergrund. Schlägt der Request fehl, wird der Zustand zurückgesetzt und der Kunde informiert. Dieses Muster fühlt sich für den Kunden instantan an, ohne dass die Datenintegrität leidet.
Der zweite zentrale Punkt ist, dass ein Produkt häufig auf mehreren Karten gleichzeitig sichtbar ist, etwa in der Produktliste und in einem Cross-Selling-Slider. Ein guter Wishlist Toggle Button hält den Zustand über alle diese Karten hinweg synchron, ohne dass jede Karte ihre eigene, isolierte Kopie des Zustands verwaltet. Die folgenden Abschnitte bauen genau diese Komponente auf, von der einzelnen Karte bis zum globalen State und der Barrierefreiheit.
2. Grundgerüst: State pro Produktkarte
Jede Produktkarte bekommt eine eigene x-data-Komponente, die die Produkt-ID und den aktuellen Wishlist-Status hält. Der initiale Status wird serverseitig über ein data-Attribut aus dem PHTML-Template in die Komponente übergeben, damit beim ersten Rendern kein zusätzlicher Request nötig ist. Das verhindert ein kurzes Aufblitzen des falschen Zustands, das entstehen würde, wenn der Status erst nach dem Laden per Fetch nachgeladen wird.
Der Wishlist Toggle Button selbst ist bewusst als eigenständige Funktion definiert, die pro Karte aufgerufen wird, aber intern auf einen globalen Store zugreift, sobald es um die produktübergreifende Synchronisation geht. Diese Trennung zwischen lokalem UI-Zustand und globalem Datenzustand ist der Kern der gesamten Komponente.
// 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;
}
}
};
}
Der Getter isInWishlist kombiniert lokalen und globalen Zustand: Solange der globale Store noch nicht initialisiert ist, greift die Karte auf ihren eigenen initialen Wert zurück. Sobald der Store geladen ist, übernimmt er die Führung. Dieser Übergang passiert für den Kunden unsichtbar und verhindert einen kurzen Sprung im Wishlist Toggle Button beim Seitenaufbau.
3. Optimistic UI: sofort umschalten, dann synchronisieren
Optimistic UI bedeutet, dass die Oberfläche so tut, als sei die Aktion bereits erfolgreich abgeschlossen, noch bevor die Serverantwort eintrifft. Für einen Wishlist Toggle Button ist das fast immer die richtige Wahl, weil die Fehlerquote bei einem einfachen Hinzufügen zur Merkliste sehr gering ist und ein Rollback im seltenen Fehlerfall unauffällig möglich ist.
Die Kunst liegt darin, den optimistischen Zustand klar vom tatsächlich bestätigten Zustand zu trennen, damit im Fehlerfall exakt dorthin zurückgesprungen werden kann, wo der Kunde vor dem Klick stand. Ein isPending-Flag zeigt dabei optional einen dezenten Ladeindikator, ohne die eigentliche Sichtbarkeit des Icons zu verändern.
<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 ? 'Von Merkliste entfernen' : 'Zur Merkliste hinzufügen'"></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>
Die Skalierung des Icons per scale-110 gibt dem Wishlist Toggle Button einen kleinen taktilen Effekt, ohne eine komplexe Animation zu benötigen. Die Änderung des fill-Attributs zwischen none und currentColor erzeugt den klassischen Wechsel vom Umriss- zum gefüllten Herz, den Kunden aus fast jeder E-Commerce-Anwendung kennen.
4. Backend-Anbindung mit GraphQL-Mutationen
Magento stellt für die Merkliste die GraphQL-Mutationen addProductsToWishlist und removeProductsFromWishlist bereit. Beide erwarten eine Wishlist-ID und ein Array von Produkt-IDs beziehungsweise Wishlist-Item-IDs. Für einen einfachen Wishlist Toggle Button reicht es, die Standard-Wishlist des Kunden zu verwenden, die über die Query customer { wishlist { id } } ermittelt wird.
Wichtig ist, dass beide Mutationen nur für angemeldete Kunden funktionieren, da die Merkliste an das Kundenkonto gebunden ist. Für Gäste braucht der Wishlist Toggle Button daher einen separaten Mechanismus, der in Abschnitt 7 behandelt wird.
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;
}
Der Fehlerfall wird bewusst über einen geworfenen Error signalisiert, damit die aufrufende Komponente aus Abschnitt 3 den optimistischen Zustand zurücksetzen kann. Diese klare Fehlerbehandlung ist entscheidend, damit der Wishlist Toggle Button niemals einen Zustand anzeigt, der nicht tatsächlich im Backend gespeichert wurde.
5. Globaler State über alle Produktkarten hinweg
Damit derselbe Wishlist Toggle Button auf mehreren Karten gleichzeitig korrekt reagiert, wird ein Alpine.store('wishlist') mit einem Set aus Produkt-IDs geführt. Ein Set ist hier die richtige Datenstruktur, weil Zugehörigkeitsprüfungen mit has() konstante Laufzeit haben, unabhängig davon, wie viele Artikel auf der Merkliste stehen.
Beim Umschalten eines Artikels aktualisiert der Store das Set zentral, wodurch jede Karte, die denselben Produkt-ID-Wert prüft, automatisch den neuen Zustand sieht, auch wenn sie an einer völlig anderen Stelle im DOM steht.
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;
}
}
});
});
Da Alpine Set-Objekte nicht automatisch reaktiv über x-text-Ausdrücke beobachtet, ist es wichtig, dass jede Karte den Store über einen Getter abfragt, wie in Abschnitt 2 gezeigt, statt das Set selbst direkt zu binden. So bleibt der Wishlist Toggle Button auf jeder Karte reaktiv, ohne dass Alpine tiefergehende Proxy-Mechanismen für komplexe Datenstrukturen benötigt.
6. Herz-Icon-Animation ohne externe Bibliothek
Eine kleine, aber wirkungsvolle Ergänzung für den Wishlist Toggle Button ist ein kurzer Puls-Effekt beim Hinzufügen zur Merkliste. Statt einer externen Animationsbibliothek reicht eine Kombination aus Tailwind-Transition-Klassen und einem kurzzeitig gesetzten x-data-Flag, das nach wenigen hundert Millisekunden automatisch wieder zurückgesetzt wird.
Dieser Ansatz vermeidet zusätzliche Abhängigkeiten und bleibt vollständig deklarativ innerhalb der Alpine-Komponente. Der Effekt wirkt subtil, verstärkt aber die gefühlte Reaktionsgeschwindigkeit des Wishlist Toggle Buttons deutlich.
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);
}
};
}
Im Markup genügt dann eine bedingte Klasse wie :class="isPulsing ? 'scale-125' : 'scale-100'" in Kombination mit transition-transform, um den Puls sichtbar zu machen. Wichtig ist, die Dauer des Timeouts exakt an die CSS-Transition-Dauer anzupassen, damit der Wishlist Toggle Button nicht mitten in der Animation zurückspringt.
7. Gastnutzer: localStorage-Fallback und Merge nach Login
Da die GraphQL-Wishlist-Mutationen ein Kundenkonto voraussetzen, braucht ein guter Wishlist Toggle Button einen Fallback für Gäste. Die pragmatische Lösung ist, Produkt-IDs im localStorage zu speichern, solange kein Kunde angemeldet ist. Der Wishlist Toggle Button verhält sich für den Gast optisch identisch, nur dass die Daten lokal im Browser statt im Backend liegen.
Nach einem erfolgreichen Login wird die lokale Liste einmalig mit der serverseitigen Merkliste zusammengeführt, über einen sequenziellen Aufruf der addProductsToWishlist-Mutation für jede lokal gespeicherte Produkt-ID. Anschließend wird der localStorage-Eintrag gelöscht, damit keine veralteten Daten zurückbleiben.
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');
}
Diese sequenzielle Verarbeitung mit for...of statt Promise.all ist bewusst gewählt, um Ratenbegrenzungen auf der GraphQL-Schnittstelle zu vermeiden, die bei parallelen Massenanfragen manche Magento-Installationen auslösen. Für den typischen Fall von wenigen gemerkten Artikeln ist die zusätzliche Latenz durch die sequenzielle Verarbeitung vernachlässigbar.
8. Barrierefreiheit: aria-pressed und Statusmeldungen
Ein Wishlist Toggle Button ist semantisch ein Toggle-Button und sollte daher immer aria-pressed statt aria-checked verwenden. Der Wert muss dynamisch an den aktuellen Zustand gebunden sein, damit Screenreader korrekt ansagen, ob der Artikel bereits gemerkt ist. Zusätzlich sollte der sichtbare Text im sr-only-Span je nach Zustand wechseln, wie in Abschnitt 3 gezeigt, damit die Handlungsaufforderung immer zum aktuellen Zustand passt.
Für eine vollständige Barrierefreiheit lohnt sich zusätzlich eine kurze Toast-Benachrichtigung nach dem Umschalten, die über eine aria-live-Region angesagt wird. So erfahren auch Screenreader-Nutzer, dass die Aktion erfolgreich war, ohne dass sie den Fokus manuell zum Wishlist Toggle Button zurückbewegen müssen.
<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
? 'Artikel wurde zur Merkliste hinzugefügt'
: 'Artikel wurde von der Merkliste entfernt';
}
}));
});
</script>
Diese Meldung sollte nach jedem erfolgreichen Toggle kurz gesetzt und nach ein bis zwei Sekunden wieder geleert werden, damit sie bei einem erneuten Klick auf denselben Wishlist Toggle Button erneut angesagt wird. Ohne diese Rücksetzung ignorieren manche Screenreader eine identische, unveränderte Textänderung.
9. Wishlist-Implementierungen im Vergleich
Es gibt verschiedene Reifegrade, mit denen ein Wishlist Toggle Button umgesetzt werden kann, von einer einfachen Formular-Übermittlung bis zur vollständigen Optimistic-UI-Lösung mit Gastnutzer-Support.
| Aspekt | Einfache Umsetzung | Empfohlenes Wishlist-Pattern | Vorteil |
|---|---|---|---|
| Reaktion auf Klick | Warten auf Server-Antwort | Optimistic UI mit Rollback | Fühlt sich instantan an |
| Zustand über Karten | Jede Karte lädt eigenen Status | Alpine.store() mit Set | Synchron, ein Request statt vieler |
| Gastnutzer | Button für Gäste ausgeblendet | localStorage-Fallback + Merge | Funktioniert ohne Login, kein Datenverlust |
| Toggle-Semantik | Nur visuelle Klasse ohne ARIA | aria-pressed + aria-live |
Für Screenreader vollständig nutzbar |
| Fehlerbehandlung | Stiller Fehlschlag ohne Rollback | Zustand bei Fehler zurücksetzen | UI zeigt niemals falschen Zustand |
Der Sprung von der einfachen zur empfohlenen Umsetzung erfordert kaum mehr Code, aber deutlich mehr Sorgfalt bei der Zustandsverwaltung. Wer den Wishlist Toggle Button von Anfang an mit Optimistic UI und globalem Store plant, spart sich spätere Refactorings, sobald das Produkt auf mehreren Seiten gleichzeitig auftaucht.
Mironsoft
Hyvä-Theme-Entwicklung und Alpine.js-Komponenten für Magento
Ein Wishlist Toggle Button, der wirklich synchron bleibt?
Wir bauen Wishlist-, Merkzettel- und weitere Interaktionsmuster als reaktive Alpine.js-Komponenten mit Optimistic UI, GraphQL-Anbindung und Gastnutzer-Support.
Wishlist & Merkliste
Optimistic UI, synchroner Zustand über alle Produktkarten hinweg
Gastnutzer-Support
localStorage-Fallback mit sauberem Merge nach Login
Barrierefreiheit
aria-pressed, Live-Regionen und vollständige Tastaturbedienung
10. Zusammenfassung
Ein gut gebauter Wishlist Toggle Button kombiniert vier Bausteine: Optimistic UI für sofortiges visuelles Feedback, einen globalen Alpine.store() mit einem Set für produktübergreifende Synchronität, eine saubere GraphQL-Anbindung mit klarer Fehlerbehandlung und einen localStorage-Fallback für Gastnutzer mit anschließendem Merge nach Login. Jeder dieser Bausteine lässt sich unabhängig testen und erweitern, ohne die anderen zu beeinflussen.
Barrierefreiheit ist bei einem Wishlist Toggle Button kein nachträglicher Zusatz, sondern Teil der Grundstruktur: aria-pressed, ein dynamischer sr-only-Text und eine aria-live-Region für Statusmeldungen kosten wenig Aufwand, machen den Button aber für alle Kunden gleichermaßen nutzbar. Wer diese Komponente einmal sauber baut, kann sie unverändert auf Produktlisten, Cross-Selling-Slidern und Produktdetailseiten wiederverwenden.
Wishlist Toggle Button mit Alpine State — Das Wichtigste auf einen Blick
Optimistic UI
Zustand sofort umschalten, bei Serverfehler zurücksetzen. Fühlt sich für den Kunden instantan an.
Globaler Store
Alpine.store('wishlist') mit einem Set hält alle Produktkarten synchron.
Gastnutzer
localStorage-Fallback, sequenzieller Merge mit der Server-Wishlist nach Login.
Barrierefreiheit
aria-pressed, dynamischer sr-only-Text und aria-live-Statusmeldung.