Skip to content

← all comparisons

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.

Each panel header shows the source size of that component file (raw, gzipped, line count). Source-only — framework runtime (React ~45 KB, Vue ~35 KB, Svelte ~5 KB gzipped) is shared across every island and isn't included in these numbers.

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}