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

React · Vue · Svelte

The same shop, built three times.

A small e-commerce demo where every feature ships in React 19, Vue 3.5, and Svelte 5 — side-by-side, sharing one cart store, so you can read the differences in idiom without translating between mental models.

Honest editorial: if you walk away with one of the three, we'd pick Svelte. The site's own chrome — theme toggle, toast notifications — runs on it for that reason. See about.

Shop

Three product lists, one fetched API, one shared cart. Add items in any column — all three columns react.

Cart

Two-way binding showcase. Type a quantity in any framework's stepper — the other two follow.

Compare

The actual source for every feature, three columns, syntax-highlighted. Always in sync with the running shop.

Hero slide banner — three timers, three cleanup idioms

Timers + cleanup

Slide banner

A setInterval that has to be torn down on unmount. The contrast is in how each framework expresses "do this on mount, undo it on unmount".

React 692 B gzip · 39 lines
SlideBannerReact.tsx
How this works

useEffect with an empty dependency array runs once after mount. The captured interval id lives in the closure; the returned function is the cleanup, called on unmount. Setup and teardown sit together — easy to read, easy to forget.

import { useEffect, useState } from 'react';
import { promos, SLIDE_INTERVAL_MS } from '../data/promos';

export default function SlideBannerReact() {
  const [i, setI] = useState(0);

  useEffect(() => {
    if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
    const id = setInterval(() => {
      setI((prev) => (prev + 1) % promos.length);
    }, SLIDE_INTERVAL_MS);
    return () => clearInterval(id);
  }, []);

  const slide = promos[i];

  return (
    <div className="relative h-48 overflow-hidden rounded-lg bg-slate-200 dark:bg-slate-700">
      <img src={slide.image} alt="" className="absolute inset-0 h-full w-full object-cover" />
      <div className="absolute inset-0 bg-black/40" />
      <div className="relative flex h-full flex-col justify-end gap-1 p-4 text-white">
        <h3 className="text-lg font-semibold">{slide.title}</h3>
        <p className="text-sm opacity-90">{slide.subtitle}</p>
      </div>
      <div className="absolute bottom-2 right-2 flex gap-1">
        {promos.map((p, idx) => (
          <button
            key={p.id}
            type="button"
            onClick={() => setI(idx)}
            aria-label={`Go to slide ${idx + 1}`}
            className={`h-2 w-2 rounded-full transition ${idx === i ? 'bg-white' : 'bg-white/40'}`}
          />
        ))}
      </div>
    </div>
  );
}
Vue 711 B gzip · 43 lines
SlideBannerVue.vue
How this works

Vue splits mount and unmount into two hooks. onMounted starts the interval; onUnmounted clears it. The timer id is held in a let outside both because they don't share a closure. More verbose than React/Svelte, but the symmetry is explicit.

<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import { promos, SLIDE_INTERVAL_MS } from '../data/promos';

const i = ref(0);
let timerId: ReturnType<typeof setInterval> | undefined;

onMounted(() => {
  if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
  timerId = setInterval(() => {
    i.value = (i.value + 1) % promos.length;
  }, SLIDE_INTERVAL_MS);
});

onUnmounted(() => {
  if (timerId) clearInterval(timerId);
});
</script>

<template>
  <div class="relative h-48 overflow-hidden rounded-lg bg-slate-200 dark:bg-slate-700">
    <img :src="promos[i].image" alt="" class="absolute inset-0 h-full w-full object-cover" />
    <div class="absolute inset-0 bg-black/40" />
    <div class="relative flex h-full flex-col justify-end gap-1 p-4 text-white">
      <h3 class="text-lg font-semibold">{{ promos[i].title }}</h3>
      <p class="text-sm opacity-90">{{ promos[i].subtitle }}</p>
    </div>
    <div class="absolute bottom-2 right-2 flex gap-1">
      <button
        v-for="(p, idx) in promos"
        :key="p.id"
        type="button"
        @click="i = idx"
        :aria-label="`Go to slide ${idx + 1}`"
        :class="[
          'h-2 w-2 rounded-full transition',
          idx === i ? 'bg-white' : 'bg-white/40',
        ]"
      />
    </div>
  </div>
</template>
Svelte 635 B gzip · 33 lines
SlideBannerSvelte.svelte
How this works

$effect is the runes-mode replacement for onMount. Its returned function is the cleanup — same shape as React's useEffect, no dependency array because the runes runtime auto-tracks reactive reads (there are none here, so it runs once).

<script lang="ts">
  import { promos, SLIDE_INTERVAL_MS } from '../data/promos';

  let i = $state(0);

  $effect(() => {
    if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
    const id = setInterval(() => {
      i = (i + 1) % promos.length;
    }, SLIDE_INTERVAL_MS);
    return () => clearInterval(id);
  });
</script>

<div class="relative h-48 overflow-hidden rounded-lg bg-slate-200 dark:bg-slate-700">
  <img src={promos[i].image} alt="" class="absolute inset-0 h-full w-full object-cover" />
  <div class="absolute inset-0 bg-black/40"></div>
  <div class="relative flex h-full flex-col justify-end gap-1 p-4 text-white">
    <h3 class="text-lg font-semibold">{promos[i].title}</h3>
    <p class="text-sm opacity-90">{promos[i].subtitle}</p>
  </div>
  <div class="absolute bottom-2 right-2 flex gap-1">
    {#each promos as p, idx (p.id)}
      <button
        type="button"
        onclick={() => (i = idx)}
        aria-label={`Go to slide ${idx + 1}`}
        class={`h-2 w-2 rounded-full transition ${idx === i ? 'bg-white' : 'bg-white/40'}`}
      ></button>
    {/each}
  </div>
</div>

Each banner advances every 3 seconds. The interesting code is how each framework starts and tears down the setInterval.

React (useEffect)

Summer Sale

Up to 40% off seasonal favorites.

Vue (onMounted/onUnmounted)

Summer Sale

Up to 40% off seasonal favorites.

Svelte ($effect)

Summer Sale

Up to 40% off seasonal favorites.

Featured carousel — three ways to grab a DOM element

DOM refs

Product carousel

Capture a DOM element from the template so the prev/next buttons can call scrollBy() on it.

React 1.1 KB gzip · 84 lines
ProductCarouselReact.tsx
How this works

useRef returns a stable object across renders. Pass it via the JSX ref prop and read element through .current. Always nullable — the element doesn't exist before the first commit. The fetch lives in useEffect with a cancelled flag.

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

const SCROLL_STEP = 240;

export default function ProductCarouselReact() {
  const [products, setProducts] = useState<Product[]>([]);
  const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading');
  const scrollerRef = useRef<HTMLDivElement>(null);

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

  const scroll = (dir: -1 | 1) => {
    scrollerRef.current?.scrollBy({ left: dir * SCROLL_STEP, behavior: 'smooth' });
  };

  if (status === 'loading') {
    return (
      <div className="flex gap-3 overflow-hidden pb-2">
        {Array.from({ length: 6 }).map((_, i) => (
          <article key={i} className="flex w-52 shrink-0 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>
          </article>
        ))}
      </div>
    );
  }
  if (status === 'error') return <p className="text-red-600">Failed to load products.</p>;

  return (
    <div className="relative">
      <button
        type="button"
        onClick={() => scroll(-1)}
        aria-label="Scroll left"
        className="absolute left-0 top-1/2 z-10 -translate-x-1/2 -translate-y-1/2 rounded-full border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 p-2 shadow hover:bg-slate-50 dark:hover:bg-slate-800"
      >‹</button>
      <div
        ref={scrollerRef}
        className="flex snap-x snap-mandatory gap-3 overflow-x-auto scroll-smooth pb-2"
      >
        {products.map((p) => (
          <a
            key={p.id}
            href={`/products/${p.slug}`}
            className="flex w-52 shrink-0 snap-start flex-col overflow-hidden rounded-md border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 hover:border-blue-300 dark:hover:border-blue-600"
          >
            <img src={p.thumbnail} alt={p.title} loading="lazy" className="aspect-square w-full object-cover" />
            <div className="flex flex-col gap-1 p-3">
              <span className="truncate text-sm font-semibold">{p.title}</span>
              <span className="tabular-nums text-blue-700 dark:text-blue-400">${p.price}</span>
            </div>
          </a>
        ))}
      </div>
      <button
        type="button"
        onClick={() => scroll(1)}
        aria-label="Scroll right"
        className="absolute right-0 top-1/2 z-10 translate-x-1/2 -translate-y-1/2 rounded-full border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 p-2 shadow hover:bg-slate-50 dark:hover:bg-slate-800"
      >›</button>
    </div>
  );
}
Vue 1.0 KB gzip · 72 lines
ProductCarouselVue.vue
How this works

Declare a normal ref(null), give the element a string name in the template (ref="scroller"), and Vue auto-binds it. Access through .value. The fetch goes in onMounted async.

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

const SCROLL_STEP = 240;

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

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

const scroll = (dir: -1 | 1) => {
  scroller.value?.scrollBy({ left: dir * SCROLL_STEP, behavior: 'smooth' });
};
</script>

<template>
  <div v-if="status === 'loading'" class="flex gap-3 overflow-hidden pb-2">
    <article
      v-for="i in 6"
      :key="i"
      class="flex w-52 shrink-0 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>
    </article>
  </div>
  <p v-else-if="status === 'error'" class="text-red-600">Failed to load products.</p>
  <div v-else class="relative">
    <button
      type="button"
      @click="scroll(-1)"
      aria-label="Scroll left"
      class="absolute left-0 top-1/2 z-10 -translate-x-1/2 -translate-y-1/2 rounded-full border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 p-2 shadow hover:bg-slate-50 dark:hover:bg-slate-800"
    >‹</button>
    <div
      ref="scroller"
      class="flex snap-x snap-mandatory gap-3 overflow-x-auto scroll-smooth pb-2"
    >
      <a
        v-for="p in products"
        :key="p.id"
        :href="`/products/${p.slug}`"
        class="flex w-52 shrink-0 snap-start flex-col overflow-hidden rounded-md border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 hover:border-blue-300 dark:hover:border-blue-600"
      >
        <img :src="p.thumbnail" :alt="p.title" loading="lazy" class="aspect-square w-full object-cover" />
        <div class="flex flex-col gap-1 p-3">
          <span class="truncate text-sm font-semibold">{{ p.title }}</span>
          <span class="tabular-nums text-blue-700 dark:text-blue-400">${{ p.price }}</span>
        </div>
      </a>
    </div>
    <button
      type="button"
      @click="scroll(1)"
      aria-label="Scroll right"
      class="absolute right-0 top-1/2 z-10 translate-x-1/2 -translate-y-1/2 rounded-full border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 p-2 shadow hover:bg-slate-50 dark:hover:bg-slate-800"
    >›</button>
  </div>
</template>
Svelte 926 B gzip · 61 lines
ProductCarouselSvelte.svelte
How this works

{#await} for the fetch — same simplification as in ProductList. bind:this={scroller} captures the element directly, no .current or .value. The $state wrap on the variable is a Svelte 5 quirk; in Svelte 4 a bare let was enough.

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

  const SCROLL_STEP = 240;
  const productsPromise = fetchProducts();

  let scroller: HTMLDivElement | undefined = $state();

  function scroll(dir: -1 | 1) {
    scroller?.scrollBy({ left: dir * SCROLL_STEP, behavior: 'smooth' });
  }
</script>

{#await productsPromise}
  <div class="flex gap-3 overflow-hidden pb-2">
    {#each Array(6) as _, i (i)}
      <article class="flex w-52 shrink-0 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>
      </article>
    {/each}
  </div>
{:then products}
  <div class="relative">
    <button
      type="button"
      onclick={() => scroll(-1)}
      aria-label="Scroll left"
      class="absolute left-0 top-1/2 z-10 -translate-x-1/2 -translate-y-1/2 rounded-full border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 p-2 shadow hover:bg-slate-50 dark:hover:bg-slate-800"
    >‹</button>
    <div
      bind:this={scroller}
      class="flex snap-x snap-mandatory gap-3 overflow-x-auto scroll-smooth pb-2"
    >
      {#each products as p (p.id)}
        <a
          href={`/products/${p.slug}`}
          class="flex w-52 shrink-0 snap-start flex-col overflow-hidden rounded-md border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 hover:border-blue-300 dark:hover:border-blue-600"
        >
          <img src={p.thumbnail} alt={p.title} loading="lazy" class="aspect-square w-full object-cover" />
          <div class="flex flex-col gap-1 p-3">
            <span class="truncate text-sm font-semibold">{p.title}</span>
            <span class="tabular-nums text-blue-700 dark:text-blue-400">${p.price}</span>
          </div>
        </a>
      {/each}
    </div>
    <button
      type="button"
      onclick={() => scroll(1)}
      aria-label="Scroll right"
      class="absolute right-0 top-1/2 z-10 translate-x-1/2 -translate-y-1/2 rounded-full border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 p-2 shadow hover:bg-slate-50 dark:hover:bg-slate-800"
    >›</button>
  </div>
{:catch}
  <p class="text-red-600">Failed to load products.</p>
{/await}

Prev/next call scrollBy() on the underlying scroll container. Each framework has its own way to capture that element.

React (useRef)
Vue (template ref)
Svelte (bind:this)