Wishlist Toggle Button mit Alpine State bauen
AI generated
x-data
Alpine
Alpine.js · Hyvä Theme · Magento · Wishlist
Wishlist Toggle Button mit Alpine State bauen
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.

17 Min. Lesezeit x-data · Optimistic UI · GraphQL · localStorage Alpine.js 3.x · Hyvä Theme · Magento 2.4

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.

11. FAQ: Wishlist Toggle Button mit Alpine State

1Was bedeutet Optimistic UI?
Die Oberfläche zeigt den neuen Zustand sofort, ohne auf die Serverantwort zu warten. Bei Fehlern wird zurückgesetzt.
2Warum ein globaler Store?
Ein Produkt erscheint oft mehrfach im DOM. Ein globaler Store hält alle Karten synchron ohne eigenen Status pro Karte.
3Wie funktioniert die Wishlist für Gäste?
Speicherung im localStorage, nach Login wird einmalig mit der Server-Wishlist zusammengeführt.
4Welche GraphQL-Mutationen werden genutzt?
addProductsToWishlist und removeProductsFromWishlist mit Wishlist-ID und Produkt-IDs.
5Warum Set statt Array?
Set bietet konstante Laufzeit für has()-Prüfungen, unabhängig von der Anzahl gemerkter Artikel.
6Wie wird der Button für Screenreader nutzbar?
Über aria-pressed, wechselnden sr-only-Text und eine aria-live-Region für die Statusmeldung.
7Was passiert bei einem fehlgeschlagenen Request?
Der Zustand wird lokal und global auf den vorherigen Wert zurückgesetzt, damit keine Karte falsch anzeigt.
8Wie funktioniert die Herz-Animation?
Ein kurzzeitiges Alpine-Flag mit Tailwind-Transition-Klassen, per setTimeout automatisch zurückgesetzt.
9Auf mehreren Seitentypen verwendbar?
Ja, dank globalem Store funktioniert die gleiche Komponente auf Liste, Slider und Detailseite.
10Werden zusätzliche Bibliotheken benötigt?
Nein, Alpine.js reicht für State, Animation und Barrierefreiheit vollständig aus.