Skip to content
Cart (synced): React0 Vue0 Svelte 0

Cart

List rendering + composition

Cart view

Iterates over the cart items and embeds the same-framework QuantityStepper for each row.

React 622 B gzip · 38 lines
CartViewReact.tsx
How this works

Object.values($cart).map(...) with a key prop on each row. Empty state is an early return. No animation — for a slide-out on remove or FLIP on reorder, you would reach for framer-motion or similar.

import { removeFromCart } from '../stores/cart';
import { useCart } from '../hooks/useCartReact';
import QuantityStepperReact from './QuantityStepperReact';

export default function CartViewReact() {
  const { items, subtotal: total } = useCart();

  if (items.length === 0) {
    return <p className="text-sm text-slate-500 dark:text-slate-400">Cart is empty.</p>;
  }

  return (
    <div className="flex flex-col gap-3">
      {items.map((item) => (
        <div key={item.id} className="flex flex-wrap items-center gap-3 rounded border border-slate-200 dark:border-slate-800 p-2">
          <img src={item.image} alt={item.title} className="h-12 w-12 shrink-0 rounded object-cover" />
          <div className="min-w-0 flex-1 truncate text-sm">{item.title}</div>
          <span className="shrink-0 tabular-nums text-blue-700 dark:text-blue-400">${item.price}</span>
          <div className="ml-auto flex shrink-0 items-center gap-3">
            <QuantityStepperReact id={item.id} />
            <button
              type="button"
              onClick={() => removeFromCart(item.id)}
              className="text-xs text-slate-500 dark:text-slate-400 hover:text-red-600"
            >
              remove
            </button>
          </div>
        </div>
      ))}
      <div className="mt-2 flex justify-end gap-3 text-sm">
        <span className="text-slate-500 dark:text-slate-400">Total</span>
        <span className="tabular-nums font-semibold">${total.toFixed(2)}</span>
      </div>
    </div>
  );
}
Vue 759 B gzip · 53 lines
CartViewVue.vue
How this works

computed for items and total. v-for with :key on the row, v-if/v-else for the empty state. <TransitionGroup> wraps the list and a small <style scoped> block defines cart-row-enter/leave/move classes for slide-and-fade animation on add/remove.

<script setup lang="ts">
import { removeFromCart } from '../stores/cart';
import { useCart } from '../composables/useCartVue';
import QuantityStepperVue from './QuantityStepperVue.vue';

const { items, subtotal: total } = useCart();
</script>

<template>
  <p v-if="items.length === 0" class="text-sm text-slate-500 dark:text-slate-400">Cart is empty.</p>
  <div v-else class="flex flex-col gap-3">
    <TransitionGroup name="cart-row" tag="div" class="flex flex-col gap-3">
      <div
        v-for="item in items"
        :key="item.id"
        class="cart-row flex flex-wrap items-center gap-3 rounded border border-slate-200 dark:border-slate-800 p-2"
      >
        <img :src="item.image" :alt="item.title" class="h-12 w-12 shrink-0 rounded object-cover" />
        <div class="min-w-0 flex-1 truncate text-sm">{{ item.title }}</div>
        <span class="shrink-0 tabular-nums text-blue-700 dark:text-blue-400">${{ item.price }}</span>
        <div class="ml-auto flex shrink-0 items-center gap-3">
          <QuantityStepperVue :id="item.id" />
          <button
            type="button"
            @click="removeFromCart(item.id)"
            class="text-xs text-slate-500 dark:text-slate-400 hover:text-red-600"
          >remove</button>
        </div>
      </div>
    </TransitionGroup>
    <div class="mt-2 flex justify-end gap-3 text-sm">
      <span class="text-slate-500 dark:text-slate-400">Total</span>
      <span class="tabular-nums font-semibold">${{ total.toFixed(2) }}</span>
    </div>
  </div>
</template>

<style scoped>
.cart-row-move,
.cart-row-enter-active,
.cart-row-leave-active {
  transition: all 200ms ease;
}
.cart-row-enter-from,
.cart-row-leave-to {
  opacity: 0;
  transform: translateX(20px);
}
.cart-row-leave-active {
  position: absolute;
}
</style>
Svelte 680 B gzip · 42 lines
CartViewSvelte.svelte
How this works

$derived for items and total. The interesting bit: animate:flip={{ duration: 200 }} on each row gives free FLIP animation when items reorder or the list shifts after a remove. transition:fade handles the entry/exit. Both come from svelte/animate and svelte/transition — no library, no CSS.

<script lang="ts">
  import { flip } from 'svelte/animate';
  import { fade } from 'svelte/transition';
  import { removeFromCart } from '../stores/cart';
  import { useCart } from '../lib/useCartSvelte.svelte';
  import QuantityStepperSvelte from './QuantityStepperSvelte.svelte';

  const totals = useCart();
  const items = $derived(totals.items);
  const total = $derived(totals.subtotal);
</script>

{#if items.length === 0}
  <p class="text-sm text-slate-500 dark:text-slate-400">Cart is empty.</p>
{:else}
  <div class="flex flex-col gap-3">
    {#each items as item (item.id)}
      <div
        class="flex flex-wrap items-center gap-3 rounded border border-slate-200 dark:border-slate-800 p-2"
        animate:flip={{ duration: 200 }}
        transition:fade={{ duration: 150 }}
      >
        <img src={item.image} alt={item.title} class="h-12 w-12 shrink-0 rounded object-cover" />
        <div class="min-w-0 flex-1 truncate text-sm">{item.title}</div>
        <span class="shrink-0 tabular-nums text-blue-700 dark:text-blue-400">${item.price}</span>
        <div class="ml-auto flex shrink-0 items-center gap-3">
          <QuantityStepperSvelte id={item.id} />
          <button
            type="button"
            onclick={() => removeFromCart(item.id)}
            class="text-xs text-slate-500 dark:text-slate-400 hover:text-red-600"
          >remove</button>
        </div>
      </div>
    {/each}
    <div class="mt-2 flex justify-end gap-3 text-sm">
      <span class="text-slate-500 dark:text-slate-400">Total</span>
      <span class="tabular-nums font-semibold">${total.toFixed(2)}</span>
    </div>
  </div>
{/if}

Two-way binding

Quantity stepper

Bind an input to a store value, with deferred commit on blur/Enter so backspacing through the number does not delete the cart row.

React 718 B gzip · 55 lines
QuantityStepperReact.tsx
How this works

No native two-way binding. A local "draft" useState mirrors the store value, useEffect re-syncs when the external qty changes, and onBlur (or Enter) commits via setQty. The most code of the three because every wire is explicit.

import { useEffect, useState } from 'react';
import { useStore } from '@nanostores/react';
import { cart, setQty } from '../stores/cart';

type Props = { id: number };

export default function QuantityStepperReact({ id }: Props) {
  const $cart = useStore(cart);
  const qty = $cart[id]?.qty ?? 0;

  // Local draft so typing doesn't commit until blur/Enter.
  const [draft, setDraft] = useState(String(qty));
  useEffect(() => {
    setDraft(String(qty));
  }, [qty]);

  const commit = () => {
    const n = Number(draft);
    if (Number.isFinite(n) && n >= 0) setQty(id, n);
    else setDraft(String(qty));
  };

  return (
    <div className="inline-flex items-center rounded border border-slate-300 dark:border-slate-700">
      <button
        type="button"
        onClick={() => setQty(id, qty - 1)}
        className="px-2 py-1 text-slate-700 dark:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800"
        aria-label="Decrease"
      >

      </button>
      <input
        type="number"
        min={0}
        value={draft}
        onChange={(e) => setDraft(e.target.value)}
        onBlur={commit}
        onKeyDown={(e) => {
          if (e.key === 'Enter') (e.currentTarget as HTMLInputElement).blur();
        }}
        className="w-12 border-x border-slate-300 dark:border-slate-700 px-2 py-1 text-center tabular-nums focus:outline-none"
      />
      <button
        type="button"
        onClick={() => setQty(id, qty + 1)}
        className="px-2 py-1 text-slate-700 dark:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800"
        aria-label="Increase"
      >
        +
      </button>
    </div>
  );
}
Vue 534 B gzip · 38 lines
QuantityStepperVue.vue
How this works

v-model.lazy.number on a writable computed (with get/set). The .lazy modifier listens to the change event (which fires on blur for inputs) instead of input. The writable computed is the standard way to v-model derived state.

<script setup lang="ts">
import { computed } from 'vue';
import { useStore } from '@nanostores/vue';
import { cart, setQty } from '../stores/cart';

const props = defineProps<{ id: number }>();
const $cart = useStore(cart);

// Writable computed = the Vue idiom for v-model on derived state.
const qty = computed<number>({
  get: () => $cart.value[props.id]?.qty ?? 0,
  set: (v) => setQty(props.id, v),
});
</script>

<template>
  <div class="inline-flex items-center rounded border border-slate-300 dark:border-slate-700">
    <button
      type="button"
      @click="qty -= 1"
      class="px-2 py-1 text-slate-700 dark:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800"
      aria-label="Decrease"
    >−</button>
    <input
      type="number"
      :min="0"
      v-model.lazy.number="qty"
      class="w-12 border-x border-slate-300 dark:border-slate-700 px-2 py-1 text-center tabular-nums focus:outline-none"
    />
    <button
      type="button"
      @click="qty += 1"
      class="px-2 py-1 text-slate-700 dark:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800"
      aria-label="Increase"
    >+</button>
  </div>
</template>
Svelte 439 B gzip · 30 lines
QuantityStepperSvelte.svelte
How this works

Deliberately not using bind:value here — instead value={qty} + onchange. The change event already fires on blur, so we get the deferred commit for free without writing a getter/setter binding.

<script lang="ts">
  import { cart, setQty } from '../stores/cart';

  let { id }: { id: number } = $props();

  const qty = $derived($cart[id]?.qty ?? 0);
</script>

<div class="inline-flex items-center rounded border border-slate-300 dark:border-slate-700">
  <button
    type="button"
    onclick={() => setQty(id, qty - 1)}
    class="px-2 py-1 text-slate-700 dark:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800"
    aria-label="Decrease"
  >−</button>
  <input
    type="number"
    min={0}
    value={qty}
    onchange={(e) => setQty(id, Number(e.currentTarget.value))}
    class="w-12 border-x border-slate-300 dark:border-slate-700 px-2 py-1 text-center tabular-nums focus:outline-none"
  />
  <button
    type="button"
    onclick={() => setQty(id, qty + 1)}
    class="px-2 py-1 text-slate-700 dark:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800"
    aria-label="Increase"
  >+</button>
</div>

Logic composition (reusable reactive logic)

useCart — hook / composable / rune function

Cart subtotal lookup is needed in both CartView and CheckoutForm. Each framework has its own pattern for extracting reactive logic into a reusable function — different name, same idea. The three live in src/hooks, src/composables, and src/lib so they read like real-world code rather than demo files.

React 391 B gzip · 25 lines
useCartReact.ts
How this works

A "custom hook" — a function whose name starts with use that internally calls other hooks. useStore subscribe" class="text-blue-700 underline decoration-blue-200 underline-offset-2 hover:decoration-blue-500">subscribes the calling component to the cart atom; useMemo recomputes items / subtotal / count only when the underlying $cart changes. Returning a frozen object keeps consumers from re-rendering on shape mutations.

import { useMemo } from 'react';
import { useStore } from '@nanostores/react';
import { cart, type CartItem } from '../stores/cart';

export type CartTotals = {
  items: CartItem[];
  subtotal: number;
  count: number;
};

/**
 * Custom hook: subscribes to the cart store and returns the items list,
 * subtotal, and total quantity. The useMemo guards against re-allocating
 * the totals object on unrelated re-renders.
 */
export function useCart(): CartTotals {
  const $cart = useStore(cart);
  return useMemo(() => {
    const items = Object.values($cart);
    const subtotal = items.reduce((s, i) => s + i.price * i.qty, 0);
    const count = items.reduce((s, i) => s + i.qty, 0);
    return { items, subtotal, count };
  }, [$cart]);
}
Vue 399 B gzip · 27 lines
useCartVue.ts
How this works

A "composable" — a plain function that returns refs / computeds. The Vue analogue of a custom hook, popularized by the Composition API. Templates auto-unwrap refs, so consumers can write {{ subtotal }} or :class without .value. The composable convention is alphabetic: any file under src/composables/ that starts with use.

import { computed, type ComputedRef } from 'vue';
import { useStore } from '@nanostores/vue';
import { cart, type CartItem } from '../stores/cart';

export type CartTotals = {
  items: ComputedRef<CartItem[]>;
  subtotal: ComputedRef<number>;
  count: ComputedRef<number>;
};

/**
 * Composable: subscribes to the cart store and exposes items / subtotal /
 * count as Vue refs so consumers can read them directly in templates and
 * track them in their own computed values.
 */
export function useCart(): CartTotals {
  const $cart = useStore(cart);
  const items = computed(() => Object.values($cart.value));
  const subtotal = computed(() =>
    items.value.reduce((s, i) => s + i.price * i.qty, 0),
  );
  const count = computed(() =>
    items.value.reduce((s, i) => s + i.qty, 0),
  );
  return { items, subtotal, count };
}
Svelte 506 B gzip · 37 lines
useCartSvelte.svelte.ts
How this works

Svelte 5 lets you use runes ($state, $derived, $effect) in any file ending in .svelte.js or .svelte.ts. fromStore() bridges a regular Svelte store (nanostores conform) into a reactive { current } shape. The function returns getters because $derived values are live — consuming a getter in a component or another rune-using function tracks the read.

import { fromStore } from 'svelte/store';
import { cart, type CartItem } from '../stores/cart';

/**
 * A rune-using function. Svelte 5 lets you use $state / $derived / $effect
 * outside .svelte components if the file ends in .svelte.ts (or .svelte.js).
 *
 * fromStore wraps a regular Svelte-compatible store (nanostores qualify) so
 * its current value is reactive. The returned object uses getters because
 * $derived values are "live" — bare values would freeze at the call site.
 */
export function useCart(): {
  readonly items: CartItem[];
  readonly subtotal: number;
  readonly count: number;
} {
  const cartRef = fromStore(cart);
  const items = $derived(Object.values(cartRef.current));
  const subtotal = $derived(
    items.reduce((s, i) => s + i.price * i.qty, 0),
  );
  const count = $derived(
    items.reduce((s, i) => s + i.qty, 0),
  );
  return {
    get items() {
      return items;
    },
    get subtotal() {
      return subtotal;
    },
    get count() {
      return count;
    },
  };
}

Three views of the same cart. The stepper input on each row is the two-way binding showcase: type a number, click ±, or change the qty in any framework's view — all three update because the underlying cart is one nanostore.

React (controlled input)

Cart is empty.

Vue (v-model)

Cart is empty.

Svelte (bind:value)

Cart is empty.