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.

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)