← Blog

Warenkorb-UX in React: optimistische Updates, Rückgängig und Geld in kleinsten Einheiten

Ein React-Warenkorb, der sich sofort anfühlt und korrekt bleibt: Geld in kleinsten Einheiten, optimistische Mengen mit Rollback, zusammengefasste Anfragen, Rückgängig und Fortschritt zum Gratisversand.

Preet Suthar · · 6 Min. Lesezeit

  • React
  • Next.js
  • E-commerce
  • UX

Ein Warenkorb ist das erste Stück eines Shops, das sich gleichzeitig sofort anfühlen und korrekt sein muss. Drückst du auf Plus, sollte sich die Zahl jetzt ändern, nicht erst nach einem Roundtrip. Aber der Server verwaltet Bestand, Preise und Rabatte, und er wird manchmal Nein sagen.

So baue ich einen Warenkorb in React und Next.js, der beides schafft: Geld als Ganzzahlen gespeichert, optimistische Updates, die sauber zurückrollen, zusammengefasste Anfragen, Rückgängig statt Bestätigungsdialogen und eine Versandkostenleiste, die nicht lügt.

Fang beim Geld an

Bevor es an die UI geht, leg fest, wie du Geld speicherst, sonst ist danach jede Summe ein bisschen falsch.

0.1 + 0.2

Das ergibt 0.30000000000000004, und ein Warenkorb addiert viele Zahlen. Speichere Beträge als Ganzzahlen in der kleinsten Einheit der Währung: Cent bei USD und Yen bei JPY, das keine kleinere Einheit hat. Genau das senden und erwarten auch Stripe und die meisten Zahlungs-APIs.

type Money = { amount: number; currency: string }

function addMoney(a: Money, b: Money): Money {
  if (a.currency !== b.currency) {
    throw new Error(`Can't add ${a.currency} to ${b.currency}`)
  }
  return { amount: a.amount + b.amount, currency: a.currency }
}

function formatMoney(money: Money, locale: string) {
  const format = new Intl.NumberFormat(locale, {
    style: "currency",
    currency: money.currency,
  })
  const digits = format.resolvedOptions().maximumFractionDigits ?? 2
  return format.format(money.amount / 10 ** digits)
}

formatMoney({ amount: 4800, currency: "USD" }, "en-US") ergibt $48.00, und derselbe Betrag in JPY ergibt ¥4,800. Intl.NumberFormat kümmert sich pro Locale um Symbole, Gruppierung und Dezimalstellen, sodass du 48,00 € in Deutschland geschenkt bekommst. Zahlungsanbieter haben ein paar Währungen, bei denen ihre kleinste Einheit vom ISO-Standard abweicht, also prüf einmal die Liste deines Anbieters.

Ablehnen, zwei Währungen zu addieren, ist keine Paranoia. Es fängt den Bug ab, bei dem ein Warenkorb in EUR einen Versandtarif in USD bekommt.

Optimistische Updates, die sich zurückrollen

Der Warenkorb sollte sich in dem Moment ändern, in dem jemand auf Plus drückt: Menge, Positionssumme und Zwischensumme. Die Anfrage läuft im Hintergrund. Schlägt sie fehl, springt die Position auf die letzte vom Server bestätigte Menge zurück und sagt darunter, warum, zum Beispiel „Nur noch 2 in Sand / Large übrig“.

useOptimistic aus React 19 passt hier gut. Der optimistische Wert wird angezeigt, solange die Transition läuft, und React verwirft ihn, wenn sie endet, sodass ein Fehler ohne zusätzlichen Code zurückgerollt wird:

const [cart, setCart] = useState(initialCart)
const [shownCart, showChange] = useOptimistic(cart, applyLineChange)

function changeQuantity(lineId: string, quantity: number) {
  startTransition(async () => {
    showChange({ lineId, quantity })
    const result = await updateLine(lineId, quantity)
    startTransition(() => {
      if (result.ok) setCart(result.cart)
      else setLineError(lineId, result.message)
    })
  })
}

Zwei Regeln halten das ehrlich. Der Server gibt nach jeder Änderung den ganzen Warenkorb zurück, und dieser ersetzt deinen, sodass Summen, Rabatte und Versand immer aus einer Quelle kommen. Und der Fehler an einer Position bleibt stehen, bis die Kundin diese Position erneut ändert, denn eine Meldung, die verschwindet, bevor sie gelesen wurde, ist dasselbe wie keine Meldung.

In Next.js kann updateLine eine Server Action sein. Next.js verbirgt in Production die Meldungen von Fehlern, die in Server Actions geworfen werden, also gib für Fehler, die du anzeigen willst, { ok: false, message } zurück, statt zu werfen.

Schnelle Klicks zusammenfassen

Wer fünf Stück von etwas will, drückt in einer Sekunde viermal auf Plus. Vier Anfragen zu senden schafft vier Gelegenheiten zum Scheitern und vier Antworten, die in falscher Reihenfolge ankommen können. Schick stattdessen eine Anfrage mit der endgültigen Menge.

const timers = useRef(new Map<string, ReturnType<typeof setTimeout>>())

function pressQuantity(lineId: string, quantity: number) {
  showLocally(lineId, quantity)
  clearTimeout(timers.current.get(lineId))
  timers.current.set(
    lineId,
    setTimeout(() => commitQuantity(lineId, quantity), 400)
  )
}

Schick die absolute Menge, niemals „plus eins“. Ein absoluter Wert lässt sich gefahrlos wiederholen und gefahrlos verspätet empfangen. Überschneiden sich doch zwei Anfragen für dieselbe Position, ignoriere jede Antwort, die älter ist als die letzte, die du gesendet hast.

Rückgängig statt „Bist du sicher?“

Ein Bestätigungsdialog beim Entfernen fügt jedem Entfernen einen Schritt hinzu, um vor dem seltenen Fehler zu schützen. Rückgängig schützt vor demselben Fehler und kostet nichts, wenn es keinen gibt.

Lass die entfernte Position für etwa fünf Sekunden zu einer kurzen Zeile zusammenfallen, „Ring Vase entfernt · Rückgängig“, und schieb die Positionen darunter nach oben. Rückgängig setzt die Position wieder an ihre alte Stelle, nicht ans Ende. Du kannst sie sofort auf dem Server entfernen und beim Rückgängigmachen wieder hinzufügen oder warten, bis die Zeile abläuft. Sofort entfernen hält den Warenkorb auf dem Server korrekt, falls der Tab geschlossen wird, deshalb ziehe ich das vor, wobei die ursprüngliche Position auf dem Client gespeichert bleibt.

Kündige das Entfernen über eine höfliche Live-Region an und halte den Fokus an einer sinnvollen Stelle, auf dem Rückgängig-Button oder der nächsten Position, statt ihn auf die Seite zurückfallen zu lassen.

Veraltete Positionen

Ein Warenkorb kann tagelang in einem Tab liegen. Wird er wieder geladen, sollte der Server Positionen markieren, deren Preis sich seit dem Hinzufügen geändert hat („Preis auf $58 gesenkt“), und Positionen, die ausverkauft sind. Eine ausverkaufte Position blockiert den Checkout, bis sie entfernt ist, und der Grund steht an der Position selbst, nicht in einem Toast.

Eine Versandkostenleiste, die die Wahrheit sagt

„Noch $12.00 bis zum kostenlosen Versand“ ist eine der wenigen Warenkorb-Meldungen, die die Leute tatsächlich mögen. Zwei Dinge musst du richtig machen:

  • Nutz dieselbe Zwischensumme wie der Server. Wird der kostenlose Versand nach Rabatten berechnet, muss die Leiste das auch tun, sonst sagt die Leiste, dass du dich qualifizierst, und der Checkout berechnet trotzdem Versand.
  • Einmal feiern. Spiel einen kurzen Moment ab, wenn die Schwelle überschritten wird, und zeig danach ein ruhiges „Du bekommst kostenlosen Versand“. Spiel ihn nicht bei jedem Render oder jedem Öffnen des Drawers erneut ab.

Nutz ein echtes Progress-Element mit Textlabel, damit die Leiste als „$138 von $150“ gelesen wird und nicht als farbiger Streifen.

Der „In den Warenkorb“-Button und das Badge

Kleinigkeiten, die die Kundin auf jeder Produktseite sieht:

  • Der Button behält seine Breite, während er von Label zu Spinner zu Häkchen wechselt, damit nichts daneben springt.
  • Der Spinner wartet etwa 300ms, bevor er erscheint. Die meisten Hinzufügungen sind schneller fertig und brauchen ihn nie.
  • Das Warenkorb-Badge ändert nur die Ziffer, die sich geändert hat, und der Drawer öffnet sich oder eine kurze Bestätigung erscheint, damit die Leute wissen, wo der Artikel gelandet ist.

Eine Checkliste

  • Geld als Ganzzahlen in kleinsten Einheiten, formatiert mit Intl.NumberFormat.
  • Addiere niemals zwei Währungen.
  • Optimistische Mengen, zurückgerollt auf den letzten bestätigten Warenkorb, mit einer Meldung an der Position.
  • Schnelle Klicks werden zu einer Anfrage mit der endgültigen Menge zusammengefasst.
  • Rückgängig beim Entfernen für etwa fünf Sekunden, mit wiederhergestellter Position.
  • Veraltete Preise und ausverkaufte Positionen werden an der Position markiert und blockieren bei Bedarf den Checkout.
  • Fortschritt zum kostenlosen Versand auf Basis derselben Zwischensumme wie auf dem Server.

Die passenden HextaUI-Blöcke

Das sind die Regeln hinter dem Cart-Block in HextaUI Pro. Er hat einen AddToCartButton, einen CartTrigger mit Live-Zähler und einen CartDrawer, der auf dem Desktop von rechts und auf Smartphones von unten hereingleitet, dazu ein CartPanel für eine vollständige Warenkorbseite. Schnelle Klicks werden zu einer Anfrage zusammengefasst, ein fehlgeschlagener Callback rollt die Position zurück und zeigt deine Meldung, entfernte Positionen fallen für fünf Sekunden zu einer Rückgängig-Zeile zusammen, und dieselbe Logik wird als useOptimisticCart für deine eigenen Layouts exportiert. Seine Beispiele decken die Shopify Storefront API und Next.js Server Actions ab.

Order Summary nimmt für den Checkout dieselbe Warenkorb-Struktur entgegen, mit Rabattcodes, geschätzten Steuern und Positionen, deren Preis sich geändert hat. Beide nutzen die Geld-Helfer aus Price, die mit ganzzahligen kleinsten Währungseinheiten arbeiten und sich weigern, zwei Währungen zu addieren. Du kannst sie alle in den Live-Vorschauen ausprobieren.

Wenn der Warenkorb steht, ist der nächste Schritt der Checkout: ein One-Page-Checkout in React und Next.js.

Häufige Fragen

Sollte ich Preise als Floats speichern? Nein. Speichere Ganzzahlen in der kleinsten Einheit der Währung und teile erst beim Formatieren.

Sollten Warenkorb-Updates optimistisch sein? Ja, für Menge und Entfernen, solange der Warenkorb des Servers nach jeder Änderung deinen ersetzt und Fehler mit einer Begründung zurückgerollt werden.

Rückgängig oder Bestätigungsdialog beim Entfernen eines Artikels? Rückgängig. Es ist schneller, wenn die Leute es ernst meinen, und genauso sicher, wenn nicht.

Wie vermeide ich eine Anfrage pro Plus-Klick? Warte nach dem letzten Klick ein paar hundert Millisekunden und schick die endgültige Menge.