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

Shop

Async fetch + derived state

Product list

Fetch on mount, render a loading/error/list state machine, and derive a filtered view from a search query.

React 1.2 KB gzip · 92 lines
ProductListReact.tsx
How this works

useState for products / status / query, useEffect with a cancelled flag for the fetch, useMemo with explicit [products, query] deps for the filter. React makes you spell out every dependency — the upside is that the data flow is fully visible.

import { useEffect, useMemo, useState } from 'react';
import { fetchProducts, type Product } from '../data/products';
import AddToCartButtonReact from './AddToCartButtonReact';
import WishlistButtonReact from './WishlistButtonReact';

export default function ProductListReact() {
  const [products, setProducts] = useState<Product[]>([]);
  const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading');
  const [query, setQuery] = useState('');

  useEffect(() => {
    let cancelled = false;
    fetchProducts()
      .then((data) => {
        if (!cancelled) {
          setProducts(data);
          setStatus('ready');
        }
      })
      .catch(() => {
        if (!cancelled) setStatus('error');
      });
    return () => {
      cancelled = true;
    };
  }, []);

  const filtered = useMemo(() => {
    const q = query.trim().toLowerCase();
    if (!q) return products;
    return products.filter((p) => p.title.toLowerCase().includes(q));
  }, [products, query]);

  if (status === 'loading') {
    return (
      <div className="grid grid-cols-[repeat(auto-fill,minmax(220px,1fr))] gap-4">
        {Array.from({ length: 8 }).map((_, i) => (
          <article key={i} className="flex animate-pulse flex-col overflow-hidden rounded-md border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900">
            <div className="aspect-square w-full bg-slate-200 dark:bg-slate-700" />
            <div className="flex flex-col gap-2 p-3">
              <div className="h-4 w-3/4 rounded bg-slate-200 dark:bg-slate-700" />
              <div className="h-3 w-1/4 rounded bg-slate-200 dark:bg-slate-700" />
              <div className="h-3 w-full rounded bg-slate-100 dark:bg-slate-800" />
              <div className="h-3 w-2/3 rounded bg-slate-100 dark:bg-slate-800" />
            </div>
          </article>
        ))}
      </div>
    );
  }
  if (status === 'error') return <p className="text-red-600">Failed to load products.</p>;

  return (
    <div className="flex flex-col gap-4">
      <div className="flex items-center gap-3">
        <input
          type="search"
          value={query}
          onChange={(e) => setQuery(e.target.value)}
          placeholder="Search products…"
          className="flex-1 rounded border border-slate-300 dark:border-slate-700 px-3 py-1.5 text-sm focus:border-blue-500 focus:outline-none"
        />
        <span className="text-xs text-slate-500 dark:text-slate-400 tabular-nums">
          {filtered.length} of {products.length}
        </span>
      </div>

      {filtered.length === 0 ? (
        <p className="text-sm text-slate-500 dark:text-slate-400">No products match "{query}".</p>
      ) : (
        <div className="grid grid-cols-[repeat(auto-fill,minmax(220px,1fr))] gap-4">
          {filtered.map((p) => (
            <article key={p.id} className="flex flex-col overflow-hidden rounded-md border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900">
              <img src={p.thumbnail} alt={p.title} loading="lazy" className="aspect-square w-full object-cover" />
              <div className="flex flex-1 flex-col gap-1 p-3">
                <span className="font-semibold">{p.title}</span>
                <span className="tabular-nums text-blue-700 dark:text-blue-400">${p.price}</span>
                <span className="line-clamp-2 text-sm text-slate-500 dark:text-slate-400">{p.description}</span>
                <div className="mt-2 flex items-center gap-2">
                  <AddToCartButtonReact product={p} />
                  <WishlistButtonReact id={p.id} />
                  <a href={`/products/${p.slug}`} className="ml-auto text-xs text-blue-700 dark:text-blue-400 hover:underline">details →</a>
                </div>
              </div>
            </article>
          ))}
        </div>
      )}
    </div>
  );
}
Vue 1.1 KB gzip · 81 lines
ProductListVue.vue
How this works

ref for state, onMounted async for the fetch, computed for the filter. No deps to declare — Vue tracks reactive reads automatically. The cancelled flag isn't needed because onMounted only runs once.

<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { fetchProducts, type Product } from '../data/products';
import AddToCartButtonVue from './AddToCartButtonVue.vue';
import WishlistButtonVue from './WishlistButtonVue.vue';

const products = ref<Product[]>([]);
const status = ref<'loading' | 'ready' | 'error'>('loading');
const query = ref('');

onMounted(async () => {
  try {
    products.value = await fetchProducts();
    status.value = 'ready';
  } catch {
    status.value = 'error';
  }
});

const filtered = computed(() => {
  const q = query.value.trim().toLowerCase();
  if (!q) return products.value;
  return products.value.filter((p) => p.title.toLowerCase().includes(q));
});
</script>

<template>
  <div v-if="status === 'loading'" class="grid grid-cols-[repeat(auto-fill,minmax(220px,1fr))] gap-4">
    <article
      v-for="i in 8"
      :key="i"
      class="flex animate-pulse flex-col overflow-hidden rounded-md border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900"
    >
      <div class="aspect-square w-full bg-slate-200 dark:bg-slate-700" />
      <div class="flex flex-col gap-2 p-3">
        <div class="h-4 w-3/4 rounded bg-slate-200 dark:bg-slate-700" />
        <div class="h-3 w-1/4 rounded bg-slate-200 dark:bg-slate-700" />
        <div class="h-3 w-full rounded bg-slate-100 dark:bg-slate-800" />
        <div class="h-3 w-2/3 rounded bg-slate-100 dark:bg-slate-800" />
      </div>
    </article>
  </div>
  <p v-else-if="status === 'error'" class="text-red-600">Failed to load products.</p>
  <div v-else class="flex flex-col gap-4">
    <div class="flex items-center gap-3">
      <input
        type="search"
        v-model="query"
        placeholder="Search products…"
        class="flex-1 rounded border border-slate-300 dark:border-slate-700 px-3 py-1.5 text-sm focus:border-blue-500 focus:outline-none"
      />
      <span class="text-xs text-slate-500 dark:text-slate-400 tabular-nums">
        {{ filtered.length }} of {{ products.length }}
      </span>
    </div>

    <p v-if="filtered.length === 0" class="text-sm text-slate-500 dark:text-slate-400">
      No products match "{{ query }}".
    </p>
    <div v-else class="grid grid-cols-[repeat(auto-fill,minmax(220px,1fr))] gap-4">
      <article
        v-for="p in filtered"
        :key="p.id"
        class="flex flex-col overflow-hidden rounded-md border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900"
      >
        <img :src="p.thumbnail" :alt="p.title" loading="lazy" class="aspect-square w-full object-cover" />
        <div class="flex flex-1 flex-col gap-1 p-3">
          <span class="font-semibold">{{ p.title }}</span>
          <span class="tabular-nums text-blue-700 dark:text-blue-400">${{ p.price }}</span>
          <span class="line-clamp-2 text-sm text-slate-500 dark:text-slate-400">{{ p.description }}</span>
          <div class="mt-2 flex items-center gap-2">
            <AddToCartButtonVue :product="p" />
            <WishlistButtonVue :id="p.id" />
            <a :href="`/products/${p.slug}`" class="ml-auto text-xs text-blue-700 dark:text-blue-400 hover:underline">details →</a>
          </div>
        </div>
      </article>
    </div>
  </div>
</template>
Svelte 1.1 KB gzip · 69 lines
ProductListSvelte.svelte
How this works

No state machine at all — {#await} is built into the template. The promise is fired once at component setup and Svelte renders the {pending / :then / :catch} branches automatically. The filtered list is a {@const} inside {:then}, which auto-recomputes when query changes. Compare to React/Vue: half the script section disappears, no cancelled flag needed.

<script lang="ts">
  import { fetchProducts } from '../data/products';
  import AddToCartButtonSvelte from './AddToCartButtonSvelte.svelte';
  import WishlistButtonSvelte from './WishlistButtonSvelte.svelte';

  // Kicked off once at component setup. {#await} subscribes the template to
  // its three states (pending / fulfilled / rejected) — no useState, no
  // useEffect, no cancelled-flag bookkeeping.
  const productsPromise = fetchProducts();

  let query = $state('');
</script>

{#await productsPromise}
  <div class="grid grid-cols-[repeat(auto-fill,minmax(220px,1fr))] gap-4">
    {#each Array(8) as _, i (i)}
      <article class="flex animate-pulse flex-col overflow-hidden rounded-md border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900">
        <div class="aspect-square w-full bg-slate-200 dark:bg-slate-700"></div>
        <div class="flex flex-col gap-2 p-3">
          <div class="h-4 w-3/4 rounded bg-slate-200 dark:bg-slate-700"></div>
          <div class="h-3 w-1/4 rounded bg-slate-200 dark:bg-slate-700"></div>
          <div class="h-3 w-full rounded bg-slate-100 dark:bg-slate-800"></div>
          <div class="h-3 w-2/3 rounded bg-slate-100 dark:bg-slate-800"></div>
        </div>
      </article>
    {/each}
  </div>
{:then products}
  {@const q = query.trim().toLowerCase()}
  {@const filtered = q ? products.filter((p) => p.title.toLowerCase().includes(q)) : products}
  <div class="flex flex-col gap-4">
    <div class="flex items-center gap-3">
      <input
        type="search"
        bind:value={query}
        placeholder="Search products…"
        class="flex-1 rounded border border-slate-300 dark:border-slate-700 px-3 py-1.5 text-sm focus:border-blue-500 focus:outline-none"
      />
      <span class="text-xs text-slate-500 dark:text-slate-400 tabular-nums">
        {filtered.length} of {products.length}
      </span>
    </div>

    {#if filtered.length === 0}
      <p class="text-sm text-slate-500 dark:text-slate-400">No products match "{query}".</p>
    {:else}
      <div class="grid grid-cols-[repeat(auto-fill,minmax(220px,1fr))] gap-4">
        {#each filtered as p (p.id)}
          <article class="flex flex-col overflow-hidden rounded-md border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900">
            <img src={p.thumbnail} alt={p.title} loading="lazy" class="aspect-square w-full object-cover" />
            <div class="flex flex-1 flex-col gap-1 p-3">
              <span class="font-semibold">{p.title}</span>
              <span class="tabular-nums text-blue-700 dark:text-blue-400">${p.price}</span>
              <span class="line-clamp-2 text-sm text-slate-500 dark:text-slate-400">{p.description}</span>
              <div class="mt-2 flex items-center gap-2">
                <AddToCartButtonSvelte product={p} />
                <WishlistButtonSvelte id={p.id} />
                <a href={`/products/${p.slug}`} class="ml-auto text-xs text-blue-700 dark:text-blue-400 hover:underline">details →</a>
              </div>
            </div>
          </article>
        {/each}
      </div>
    {/if}
  </div>
{:catch}
  <p class="text-red-600">Failed to load products.</p>
{/await}

Events + global store subscription

Add to cart button

Each button reads the same nanostore to know how many of this product are already in the cart, and writes to it on click.

React 362 B gzip · 21 lines
AddToCartButtonReact.tsx
How this works

useStore from @nanostores/react subscribe" class="text-blue-700 underline decoration-blue-200 underline-offset-2 hover:decoration-blue-500">subscribes the component to the store and re-renders on every change. Calling addToCart() outside the component just mutates the atom; the subscription propagates the new value back in.

import { useStore } from '@nanostores/react';
import { cart, addToCart } from '../stores/cart';
import type { Product } from '../data/products';

type Props = { product: Product };

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

  return (
    <button
      type="button"
      onClick={() => addToCart(product)}
      className="rounded bg-blue-700 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-800"
    >
      {qty > 0 ? `In cart · ${qty}` : 'Add to cart'}
    </button>
  );
}
Vue 377 B gzip · 22 lines
AddToCartButtonVue.vue
How this works

useStore from @nanostores/vue returns a Vue ref. Use computed() to derive the qty so it stays reactive in templates. Reading $cart.value[id]?.qty inside computed registers the dependency.

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

const props = defineProps<{ product: Product }>();

const $cart = useStore(cart);
const qty = computed(() => $cart.value[props.product.id]?.qty ?? 0);
</script>

<template>
  <button
    type="button"
    @click="addToCart(props.product)"
    class="rounded bg-blue-700 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-800"
  >
    {{ qty > 0 ? `In cart · ${qty}` : 'Add to cart' }}
  </button>
</template>
Svelte 314 B gzip · 17 lines
AddToCartButtonSvelte.svelte
How this works

Just import the store and read it as $cart — Svelte's $-prefix auto-subscription works for any object with a Svelte-compatible subscribe" class="text-blue-700 underline decoration-blue-200 underline-offset-2 hover:decoration-blue-500">subscribe() (nanostores conforms). qty is a $derived rune so it recomputes when the store changes.

<script lang="ts">
  import { cart, addToCart } from '../stores/cart';
  import type { Product } from '../data/products';

  let { product }: { product: Product } = $props();

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

<button
  type="button"
  onclick={() => addToCart(product)}
  class="rounded bg-blue-700 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-800"
>
  {qty > 0 ? `In cart · ${qty}` : 'Add to cart'}
</button>

Local UI state + persistent global store

Wishlist toggle

A heart-shaped toggle on each product card that persists across sessions. The button reads "is this in the wishlist?" from a derived value and toggles the entry on click — same shape as the cart, but the store is a Set-of-ids and the state read is a boolean.

React 453 B gzip · 26 lines
WishlistButtonReact.tsx
How this works

useStore(wishlist) gives the current map; saved is a plain boolean derived inline. aria-pressed on the button + class swap based on saved is the entire visual state.

import { useStore } from '@nanostores/react';
import { wishlist, toggleWishlist } from '../stores/wishlist';

type Props = { id: number };

export default function WishlistButtonReact({ id }: Props) {
  const $wishlist = useStore(wishlist);
  const saved = !!$wishlist[id];

  return (
    <button
      type="button"
      onClick={() => toggleWishlist(id)}
      aria-label={saved ? 'Remove from wishlist' : 'Save to wishlist'}
      aria-pressed={saved}
      className={`flex h-8 w-8 items-center justify-center rounded-full border text-base transition ${
        saved
          ? 'border-rose-300 bg-rose-50 text-rose-600'
          : 'border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 text-slate-400 hover:text-rose-500'
      }`}
    >
      {saved ? '♥' : '♡'}
    </button>
  );
}
Vue 469 B gzip · 27 lines
WishlistButtonVue.vue
How this works

useStore returns a Vue ref; computed wraps the boolean lookup so the template auto-tracks it. :class array switches between two Tailwind sets.

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

const props = defineProps<{ id: number }>();
const $wishlist = useStore(wishlist);
const saved = computed(() => !!$wishlist.value[props.id]);
</script>

<template>
  <button
    type="button"
    @click="toggleWishlist(props.id)"
    :aria-label="saved ? 'Remove from wishlist' : 'Save to wishlist'"
    :aria-pressed="saved"
    :class="[
      'flex h-8 w-8 items-center justify-center rounded-full border text-base transition',
      saved
        ? 'border-rose-300 bg-rose-50 text-rose-600'
        : 'border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 text-slate-400 hover:text-rose-500',
    ]"
  >
    {{ saved ? '♥' : '♡' }}
  </button>
</template>
Svelte 403 B gzip · 21 lines
WishlistButtonSvelte.svelte
How this works

$wishlist auto-subscription, then saved is a $derived rune over the lookup. Pure runes, no adapter — same five lines you would write to read any other store.

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

  let { id }: { id: number } = $props();
  const saved = $derived(!!$wishlist[id]);
</script>

<button
  type="button"
  onclick={() => toggleWishlist(id)}
  aria-label={saved ? 'Remove from wishlist' : 'Save to wishlist'}
  aria-pressed={saved}
  class={`flex h-8 w-8 items-center justify-center rounded-full border text-base transition ${
    saved
      ? 'border-rose-300 bg-rose-50 text-rose-600'
      : 'border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 text-slate-400 hover:text-rose-500'
  }`}
>
  {saved ? '♥' : '♡'}
</button>

Read-only global state

Cart badge

The smallest possible store consumer. Subscribes to a computed total and renders it.

React 291 B gzip · 13 lines
CartBadgeReact.tsx
How this works

useStore on the computed total. The component re-renders only when cartCount changes, not on every cart mutation that doesn't affect the sum.

import { useStore } from '@nanostores/react';
import { cartCount } from '../stores/cart';

export default function CartBadgeReact() {
  const count = useStore(cartCount);
  return (
    <span className="inline-flex items-center gap-1.5 rounded-full bg-sky-100 px-3 py-1 text-sm font-medium text-sky-900">
      <span className="text-xs uppercase tracking-wider text-sky-700">React</span>
      <span className="tabular-nums">{count}</span>
    </span>
  );
}
Vue 280 B gzip · 14 lines
CartBadgeVue.vue
How this works

useStore returns a ref. Templates auto-unwrap refs, so {{ count }} just works without .value.

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

const count = useStore(cartCount);
</script>

<template>
  <span class="inline-flex items-center gap-1.5 rounded-full bg-emerald-100 px-3 py-1 text-sm font-medium text-emerald-900">
    <span class="text-xs uppercase tracking-wider text-emerald-700">Vue</span>
    <span class="tabular-nums">{{ count }}</span>
  </span>
</template>
Svelte 233 B gzip · 9 lines
CartBadgeSvelte.svelte
How this works

$cartCount auto-subscription. Three lines of script, zero subscription boilerplate.

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

<span class="inline-flex items-center gap-1.5 rounded-full bg-orange-100 px-3 py-1 text-sm font-medium text-orange-900">
  <span class="text-xs uppercase tracking-wider text-orange-700">Svelte</span>
  <span class="tabular-nums">{$cartCount}</span>
</span>

Composition (children / slot / snippet)

Toast notifications

A reusable Toast wrapper that takes arbitrary content. The Toaster fires one for every add-to-cart and wishlist toggle. The interesting comparison is how each framework lets a parent pass markup into a child slot.

React 529 B gzip · 36 lines
ToastReact.tsx
How this works

children is just a typed prop — ReactNode covers strings, elements, fragments, anything. The wrapper renders {children} where the slot belongs. The Toaster has no enter/leave animation — for that, you would install framer-motion (<AnimatePresence>) or react-transition-group.

import type { ReactNode } from 'react';
import type { ToastVariant } from '../stores/toasts';

type Props = {
  variant?: ToastVariant;
  onDismiss?: () => void;
  children: ReactNode;
};

const variantClasses: Record<ToastVariant, string> = {
  info: 'border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100',
  success: 'border-emerald-200 bg-emerald-50 text-emerald-900',
  wishlist: 'border-rose-200 bg-rose-50 text-rose-900',
};

export default function ToastReact({ variant = 'info', onDismiss, children }: Props) {
  return (
    <div
      role="status"
      className={`flex items-start gap-3 rounded-md border px-3 py-2 text-sm shadow-md ${variantClasses[variant]}`}
    >
      <div className="flex-1">{children}</div>
      {onDismiss && (
        <button
          type="button"
          onClick={onDismiss}
          aria-label="Dismiss"
          className="text-slate-400 hover:text-slate-700 dark:hover:text-slate-200"
        >

        </button>
      )}
    </div>
  );
}
Vue 531 B gzip · 39 lines
ToastVue.vue
How this works

A default <slot /> in the template renders whatever the parent put between <Toast>...</Toast>. The Toaster wraps its v-for in <TransitionGroup name="toast" tag="div"> and a <style scoped> defines toast-enter/leave classes for slide-in/out — built-in, no library.

<script setup lang="ts">
import type { ToastVariant } from '../stores/toasts';

const props = withDefaults(
  defineProps<{
    variant?: ToastVariant;
    onDismiss?: () => void;
  }>(),
  { variant: 'info', onDismiss: undefined },
);

const variantClasses: Record<ToastVariant, string> = {
  info: 'border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100',
  success: 'border-emerald-200 bg-emerald-50 text-emerald-900',
  wishlist: 'border-rose-200 bg-rose-50 text-rose-900',
};
</script>

<template>
  <div
    role="status"
    :class="[
      'flex items-start gap-3 rounded-md border px-3 py-2 text-sm shadow-md',
      variantClasses[props.variant],
    ]"
  >
    <div class="flex-1">
      <slot />
    </div>
    <button
      v-if="props.onDismiss"
      type="button"
      @click="props.onDismiss?.()"
      aria-label="Dismiss"
      class="text-slate-400 hover:text-slate-700 dark:hover:text-slate-200"
    >✕</button>
  </div>
</template>
Svelte 519 B gzip · 38 lines
ToastSvelte.svelte
How this works

Svelte 5 replaces the old <slot /> with snippets. Children come in as a Snippet prop and are rendered with {@render children()}. The Toaster adds transition:fly={{ x: 20, duration: 200 }} as a one-liner on each toast wrapper — Svelte ships transition primitives in svelte/transition, no library, no CSS classes.

<script lang="ts">
  import type { Snippet } from 'svelte';
  import type { ToastVariant } from '../stores/toasts';

  let {
    variant = 'info',
    onDismiss,
    children,
  }: {
    variant?: ToastVariant;
    onDismiss?: () => void;
    children: Snippet;
  } = $props();

  const variantClasses: Record<ToastVariant, string> = {
    info: 'border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100',
    success: 'border-emerald-200 bg-emerald-50 text-emerald-900',
    wishlist: 'border-rose-200 bg-rose-50 text-rose-900',
  };
</script>

<div
  role="status"
  class={`flex items-start gap-3 rounded-md border px-3 py-2 text-sm shadow-md ${variantClasses[variant]}`}
>
  <div class="flex-1">
    {@render children()}
  </div>
  {#if onDismiss}
    <button
      type="button"
      onclick={() => onDismiss?.()}
      aria-label="Dismiss"
      class="text-slate-400 hover:text-slate-700 dark:hover:text-slate-200"
    >✕</button>
  {/if}
</div>

The same product list, fetched from DummyJSON and rendered three times. Click "Add to cart" in any framework's column — the badges in the header (one per framework) update together because they all subscribe to the same nanostore.

React

Vue

Svelte