Skip to content

← all comparisons

Forms + validation + radio groups

Checkout form

Seven fields including a phone number and a shipping radio group, all validated. Live order summary derives subtotal from the cart store, looks up the selected shipping option, and surfaces a free-shipping threshold. Submit is gated until everything is valid; success replaces the form inline.

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 2.0 KB gzip · 146 lines
CheckoutFormReact.tsx
How this works

A useState per field plus a useMemo for the errors map (all seven fields in the deps), and three more useMemos for selectedOption / shippingFee / total derived from the cart subtotal. Radio inputs are explicit checked + onChange — no shortcut for groups in React. A small Field helper keeps the repeating JSX tolerable.

import { useMemo, useState, type FormEvent, type ReactNode } from 'react';
import { useCart } from '../hooks/useCartReact';
import { shippingOptions, effectiveShippingFee } from '../data/shipping';

const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const inputCls = 'rounded border border-slate-300 dark:border-slate-700 px-2 py-1 text-sm focus:border-blue-500 focus:outline-none';

type FieldKey = 'name' | 'email' | 'phone' | 'address' | 'city' | 'zip' | 'shipping';
type Errors = Partial<Record<FieldKey, string>>;
type Touched = Partial<Record<FieldKey, boolean>>;

export default function CheckoutFormReact() {
  const { subtotal } = useCart();

  const [name, setName] = useState('');
  const [email, setEmail] = useState('');
  const [phone, setPhone] = useState('');
  const [address, setAddress] = useState('');
  const [city, setCity] = useState('');
  const [zip, setZip] = useState('');
  const [shipping, setShipping] = useState('');
  const [touched, setTouched] = useState<Touched>({});
  const [submitted, setSubmitted] = useState(false);

  const touch = (key: FieldKey) => {
    setTouched((t) => (t[key] ? t : { ...t, [key]: true }));
  };

  const errors = useMemo<Errors>(() => {
    const e: Errors = {};
    if (name.trim().length < 2) e.name = 'Name is required.';
    if (!EMAIL_RE.test(email)) e.email = 'Valid email required.';
    if (phone.replace(/\D/g, '').length < 10) e.phone = 'At least 10 digits.';
    if (!address.trim()) e.address = 'Address is required.';
    if (!city.trim()) e.city = 'City is required.';
    if (zip.trim().length < 4) e.zip = 'Zip is required.';
    if (!shippingOptions.some((o) => o.id === shipping)) e.shipping = 'Pick a shipping method.';
    return e;
  }, [name, email, phone, address, city, zip, shipping]);

  const isValid = Object.keys(errors).length === 0;

  const selectedOption = useMemo(
    () => shippingOptions.find((o) => o.id === shipping),
    [shipping],
  );
  const shippingFee = useMemo(
    () => (selectedOption ? effectiveShippingFee(selectedOption, subtotal) : 0),
    [selectedOption, subtotal],
  );
  const total = subtotal + shippingFee;

  const handleSubmit = (e: FormEvent) => {
    e.preventDefault();
    if (isValid) setSubmitted(true);
  };

  const showError = (key: FieldKey) => (touched[key] ? errors[key] : undefined);

  if (submitted) {
    return (
      <div className="rounded border border-green-200 bg-green-50 p-3 text-sm text-green-900">
        <p className="mb-1 font-semibold">Order placed (mock).</p>
        <p>Thanks {name}, shipping via {selectedOption?.name} ({selectedOption?.eta}).</p>
        <p className="mt-2 tabular-nums">Total charged: ${total.toFixed(2)}</p>
      </div>
    );
  }

  return (
    <form onSubmit={handleSubmit} className="flex flex-col gap-2" noValidate>
      <Field label="Name" error={showError('name')}>
        <input className={inputCls} value={name} onChange={(e) => { setName(e.target.value); touch('name'); }} onBlur={() => touch('name')} />
      </Field>
      <Field label="Email" error={showError('email')}>
        <input className={inputCls} type="email" value={email} onChange={(e) => { setEmail(e.target.value); touch('email'); }} onBlur={() => touch('email')} />
      </Field>
      <Field label="Phone" error={showError('phone')}>
        <input className={inputCls} type="tel" value={phone} onChange={(e) => { setPhone(e.target.value); touch('phone'); }} onBlur={() => touch('phone')} />
      </Field>
      <Field label="Address" error={showError('address')}>
        <input className={inputCls} value={address} onChange={(e) => { setAddress(e.target.value); touch('address'); }} onBlur={() => touch('address')} />
      </Field>
      <div className="grid grid-cols-2 gap-2">
        <Field label="City" error={showError('city')}>
          <input className={inputCls} value={city} onChange={(e) => { setCity(e.target.value); touch('city'); }} onBlur={() => touch('city')} />
        </Field>
        <Field label="Zip" error={showError('zip')}>
          <input className={inputCls} value={zip} onChange={(e) => { setZip(e.target.value); touch('zip'); }} onBlur={() => touch('zip')} />
        </Field>
      </div>

      <fieldset className="mt-2 flex flex-col gap-1">
        <legend className="text-sm text-slate-600 dark:text-slate-300">Shipping</legend>
        {shippingOptions.map((opt) => {
          const fee = effectiveShippingFee(opt, subtotal);
          const isFree = fee === 0;
          return (
            <label key={opt.id} className="flex items-center gap-2 text-sm">
              <input
                type="radio"
                name="shipping-react"
                value={opt.id}
                checked={shipping === opt.id}
                onChange={(e) => { setShipping(e.target.value); touch('shipping'); }}
              />
              <span className="flex-1">
                {opt.name} <span className="text-xs text-slate-500 dark:text-slate-400">· {opt.eta}</span>
              </span>
              <span className="tabular-nums">{isFree ? 'Free' : `$${fee.toFixed(2)}`}</span>
            </label>
          );
        })}
        {showError('shipping') && <span className="text-xs text-red-600">{showError('shipping')}</span>}
      </fieldset>

      <dl className="mt-3 grid grid-cols-[1fr_auto] gap-x-3 gap-y-1 border-t border-slate-200 dark:border-slate-800 pt-2 text-sm">
        <dt className="text-slate-600 dark:text-slate-300">Subtotal</dt>
        <dd className="tabular-nums">${subtotal.toFixed(2)}</dd>
        <dt className="text-slate-600 dark:text-slate-300">Shipping</dt>
        <dd className="tabular-nums">{selectedOption ? (shippingFee === 0 ? 'Free' : `$${shippingFee.toFixed(2)}`) : '—'}</dd>
        <dt className="font-semibold">Total</dt>
        <dd className="tabular-nums font-semibold">${total.toFixed(2)}</dd>
      </dl>

      <button
        type="submit"
        className="mt-2 self-start rounded bg-blue-700 px-4 py-1.5 text-sm font-medium text-white hover:bg-blue-800 disabled:cursor-not-allowed disabled:opacity-50"
        disabled={!isValid}
      >
        Place order
      </button>
    </form>
  );
}

function Field({ label, error, children }: { label: string; error?: string; children: ReactNode }) {
  return (
    <label className="flex flex-col gap-1 text-sm">
      <span className="text-slate-600 dark:text-slate-300">{label}</span>
      {children}
      {error && <span className="text-xs text-red-600">{error}</span>}
    </label>
  );
}
Vue 1.8 KB gzip · 144 lines
CheckoutFormVue.vue
How this works

A ref per field, computed for errors and for the order summary. v-model handles text inputs AND the radio group — same directive, no special-case for groups. Cart subtotal comes through useStore + computed.

<script setup lang="ts">
import { computed, reactive, ref } from 'vue';
import { useCart } from '../composables/useCartVue';
import { shippingOptions, effectiveShippingFee } from '../data/shipping';

const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const inputCls = 'rounded border border-slate-300 dark:border-slate-700 px-2 py-1 text-sm focus:border-blue-500 focus:outline-none';

type FieldKey = 'name' | 'email' | 'phone' | 'address' | 'city' | 'zip' | 'shipping';

const { subtotal } = useCart();

const name = ref('');
const email = ref('');
const phone = ref('');
const address = ref('');
const city = ref('');
const zip = ref('');
const shipping = ref('');
const touched = reactive<Partial<Record<FieldKey, boolean>>>({});
const submitted = ref(false);

const touch = (key: FieldKey) => {
  touched[key] = true;
};

const errors = computed(() => {
  const e: Partial<Record<FieldKey, string>> = {};
  if (name.value.trim().length < 2) e.name = 'Name is required.';
  if (!EMAIL_RE.test(email.value)) e.email = 'Valid email required.';
  if (phone.value.replace(/\D/g, '').length < 10) e.phone = 'At least 10 digits.';
  if (!address.value.trim()) e.address = 'Address is required.';
  if (!city.value.trim()) e.city = 'City is required.';
  if (zip.value.trim().length < 4) e.zip = 'Zip is required.';
  if (!shippingOptions.some((o) => o.id === shipping.value)) e.shipping = 'Pick a shipping method.';
  return e;
});

const isValid = computed(() => Object.keys(errors.value).length === 0);

const selectedOption = computed(() =>
  shippingOptions.find((o) => o.id === shipping.value),
);
const shippingFee = computed(() =>
  selectedOption.value ? effectiveShippingFee(selectedOption.value, subtotal.value) : 0,
);
const total = computed(() => subtotal.value + shippingFee.value);

function handleSubmit() {
  if (isValid.value) submitted.value = true;
}

const showError = (key: FieldKey) => (touched[key] ? errors.value[key] : undefined);
</script>

<template>
  <div
    v-if="submitted"
    class="rounded border border-green-200 bg-green-50 p-3 text-sm text-green-900"
  >
    <p class="mb-1 font-semibold">Order placed (mock).</p>
    <p>Thanks {{ name }}, shipping via {{ selectedOption?.name }} ({{ selectedOption?.eta }}).</p>
    <p class="mt-2 tabular-nums">Total charged: ${{ total.toFixed(2) }}</p>
  </div>
  <form v-else @submit.prevent="handleSubmit" class="flex flex-col gap-2" novalidate>
    <label class="flex flex-col gap-1 text-sm">
      <span class="text-slate-600 dark:text-slate-300">Name</span>
      <input :class="inputCls" v-model="name" @input="touch('name')" @blur="touch('name')" />
      <span v-if="showError('name')" class="text-xs text-red-600">{{ showError('name') }}</span>
    </label>
    <label class="flex flex-col gap-1 text-sm">
      <span class="text-slate-600 dark:text-slate-300">Email</span>
      <input :class="inputCls" type="email" v-model="email" @input="touch('email')" @blur="touch('email')" />
      <span v-if="showError('email')" class="text-xs text-red-600">{{ showError('email') }}</span>
    </label>
    <label class="flex flex-col gap-1 text-sm">
      <span class="text-slate-600 dark:text-slate-300">Phone</span>
      <input :class="inputCls" type="tel" v-model="phone" @input="touch('phone')" @blur="touch('phone')" />
      <span v-if="showError('phone')" class="text-xs text-red-600">{{ showError('phone') }}</span>
    </label>
    <label class="flex flex-col gap-1 text-sm">
      <span class="text-slate-600 dark:text-slate-300">Address</span>
      <input :class="inputCls" v-model="address" @input="touch('address')" @blur="touch('address')" />
      <span v-if="showError('address')" class="text-xs text-red-600">{{ showError('address') }}</span>
    </label>
    <div class="grid grid-cols-2 gap-2">
      <label class="flex flex-col gap-1 text-sm">
        <span class="text-slate-600 dark:text-slate-300">City</span>
        <input :class="inputCls" v-model="city" @input="touch('city')" @blur="touch('city')" />
        <span v-if="showError('city')" class="text-xs text-red-600">{{ showError('city') }}</span>
      </label>
      <label class="flex flex-col gap-1 text-sm">
        <span class="text-slate-600 dark:text-slate-300">Zip</span>
        <input :class="inputCls" v-model="zip" @input="touch('zip')" @blur="touch('zip')" />
        <span v-if="showError('zip')" class="text-xs text-red-600">{{ showError('zip') }}</span>
      </label>
    </div>

    <fieldset class="mt-2 flex flex-col gap-1">
      <legend class="text-sm text-slate-600 dark:text-slate-300">Shipping</legend>
      <label
        v-for="opt in shippingOptions"
        :key="opt.id"
        class="flex items-center gap-2 text-sm"
      >
        <input
          type="radio"
          name="shipping-vue"
          :value="opt.id"
          v-model="shipping"
          @change="touch('shipping')"
        />
        <span class="flex-1">
          {{ opt.name }}
          <span class="text-xs text-slate-500 dark:text-slate-400">· {{ opt.eta }}</span>
        </span>
        <span class="tabular-nums">
          {{ effectiveShippingFee(opt, subtotal) === 0 ? 'Free' : `$${effectiveShippingFee(opt, subtotal).toFixed(2)}` }}
        </span>
      </label>
      <span v-if="showError('shipping')" class="text-xs text-red-600">{{ showError('shipping') }}</span>
    </fieldset>

    <dl class="mt-3 grid grid-cols-[1fr_auto] gap-x-3 gap-y-1 border-t border-slate-200 dark:border-slate-800 pt-2 text-sm">
      <dt class="text-slate-600 dark:text-slate-300">Subtotal</dt>
      <dd class="tabular-nums">${{ subtotal.toFixed(2) }}</dd>
      <dt class="text-slate-600 dark:text-slate-300">Shipping</dt>
      <dd class="tabular-nums">
        {{ selectedOption ? (shippingFee === 0 ? 'Free' : `$${shippingFee.toFixed(2)}`) : '—' }}
      </dd>
      <dt class="font-semibold">Total</dt>
      <dd class="tabular-nums font-semibold">${{ total.toFixed(2) }}</dd>
    </dl>

    <button
      type="submit"
      class="mt-2 self-start rounded bg-blue-700 px-4 py-1.5 text-sm font-medium text-white hover:bg-blue-800 disabled:cursor-not-allowed disabled:opacity-50"
      :disabled="!isValid"
    >
      Place order
    </button>
  </form>
</template>
Svelte 1.8 KB gzip · 140 lines
CheckoutFormSvelte.svelte
How this works

$state per field, $derived.by for the errors map, more $derived for the summary. Radio group uses bind:group={shipping} — the cleanest of the three for a one-of-many input. Cart store is read with $-prefix auto-subscription, no adapter.

<script lang="ts">
  import { useCart } from '../lib/useCartSvelte.svelte';
  import { shippingOptions, effectiveShippingFee } from '../data/shipping';

  type FieldKey = 'name' | 'email' | 'phone' | 'address' | 'city' | 'zip' | 'shipping';

  const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  const inputCls = 'rounded border border-slate-300 dark:border-slate-700 px-2 py-1 text-sm focus:border-blue-500 focus:outline-none';

  const cartTotals = useCart();
  const subtotal = $derived(cartTotals.subtotal);

  let name = $state('');
  let email = $state('');
  let phone = $state('');
  let address = $state('');
  let city = $state('');
  let zip = $state('');
  let shipping = $state('');
  let touched = $state<Partial<Record<FieldKey, boolean>>>({});
  let submitted = $state(false);

  const touch = (key: FieldKey) => {
    if (!touched[key]) touched = { ...touched, [key]: true };
  };

  const errors = $derived.by(() => {
    const e: Partial<Record<FieldKey, string>> = {};
    if (name.trim().length < 2) e.name = 'Name is required.';
    if (!EMAIL_RE.test(email)) e.email = 'Valid email required.';
    if (phone.replace(/\D/g, '').length < 10) e.phone = 'At least 10 digits.';
    if (!address.trim()) e.address = 'Address is required.';
    if (!city.trim()) e.city = 'City is required.';
    if (zip.trim().length < 4) e.zip = 'Zip is required.';
    if (!shippingOptions.some((o) => o.id === shipping)) e.shipping = 'Pick a shipping method.';
    return e;
  });

  const isValid = $derived(Object.keys(errors).length === 0);

  const selectedOption = $derived(
    shippingOptions.find((o) => o.id === shipping),
  );
  const shippingFee = $derived(
    selectedOption ? effectiveShippingFee(selectedOption, subtotal) : 0,
  );
  const total = $derived(subtotal + shippingFee);

  function handleSubmit(e: SubmitEvent) {
    e.preventDefault();
    if (isValid) submitted = true;
  }

  const showError = (key: FieldKey) => (touched[key] ? errors[key] : undefined);
</script>

{#if submitted}
  <div class="rounded border border-green-200 bg-green-50 p-3 text-sm text-green-900">
    <p class="mb-1 font-semibold">Order placed (mock).</p>
    <p>Thanks {name}, shipping via {selectedOption?.name} ({selectedOption?.eta}).</p>
    <p class="mt-2 tabular-nums">Total charged: ${total.toFixed(2)}</p>
  </div>
{:else}
  <form onsubmit={handleSubmit} class="flex flex-col gap-2" novalidate>
    <label class="flex flex-col gap-1 text-sm">
      <span class="text-slate-600 dark:text-slate-300">Name</span>
      <input class={inputCls} bind:value={name} oninput={() => touch('name')} onblur={() => touch('name')} />
      {#if showError('name')}<span class="text-xs text-red-600">{showError('name')}</span>{/if}
    </label>
    <label class="flex flex-col gap-1 text-sm">
      <span class="text-slate-600 dark:text-slate-300">Email</span>
      <input class={inputCls} type="email" bind:value={email} oninput={() => touch('email')} onblur={() => touch('email')} />
      {#if showError('email')}<span class="text-xs text-red-600">{showError('email')}</span>{/if}
    </label>
    <label class="flex flex-col gap-1 text-sm">
      <span class="text-slate-600 dark:text-slate-300">Phone</span>
      <input class={inputCls} type="tel" bind:value={phone} oninput={() => touch('phone')} onblur={() => touch('phone')} />
      {#if showError('phone')}<span class="text-xs text-red-600">{showError('phone')}</span>{/if}
    </label>
    <label class="flex flex-col gap-1 text-sm">
      <span class="text-slate-600 dark:text-slate-300">Address</span>
      <input class={inputCls} bind:value={address} oninput={() => touch('address')} onblur={() => touch('address')} />
      {#if showError('address')}<span class="text-xs text-red-600">{showError('address')}</span>{/if}
    </label>
    <div class="grid grid-cols-2 gap-2">
      <label class="flex flex-col gap-1 text-sm">
        <span class="text-slate-600 dark:text-slate-300">City</span>
        <input class={inputCls} bind:value={city} oninput={() => touch('city')} onblur={() => touch('city')} />
        {#if showError('city')}<span class="text-xs text-red-600">{showError('city')}</span>{/if}
      </label>
      <label class="flex flex-col gap-1 text-sm">
        <span class="text-slate-600 dark:text-slate-300">Zip</span>
        <input class={inputCls} bind:value={zip} oninput={() => touch('zip')} onblur={() => touch('zip')} />
        {#if showError('zip')}<span class="text-xs text-red-600">{showError('zip')}</span>{/if}
      </label>
    </div>

    <fieldset class="mt-2 flex flex-col gap-1">
      <legend class="text-sm text-slate-600 dark:text-slate-300">Shipping</legend>
      {#each shippingOptions as opt (opt.id)}
        {@const fee = effectiveShippingFee(opt, subtotal)}
        <label class="flex items-center gap-2 text-sm">
          <input
            type="radio"
            name="shipping-svelte"
            value={opt.id}
            bind:group={shipping}
            onchange={() => touch('shipping')}
          />
          <span class="flex-1">
            {opt.name}
            <span class="text-xs text-slate-500 dark:text-slate-400">· {opt.eta}</span>
          </span>
          <span class="tabular-nums">{fee === 0 ? 'Free' : `$${fee.toFixed(2)}`}</span>
        </label>
      {/each}
      {#if showError('shipping')}<span class="text-xs text-red-600">{showError('shipping')}</span>{/if}
    </fieldset>

    <dl class="mt-3 grid grid-cols-[1fr_auto] gap-x-3 gap-y-1 border-t border-slate-200 dark:border-slate-800 pt-2 text-sm">
      <dt class="text-slate-600 dark:text-slate-300">Subtotal</dt>
      <dd class="tabular-nums">${subtotal.toFixed(2)}</dd>
      <dt class="text-slate-600 dark:text-slate-300">Shipping</dt>
      <dd class="tabular-nums">
        {selectedOption ? (shippingFee === 0 ? 'Free' : `$${shippingFee.toFixed(2)}`) : '—'}
      </dd>
      <dt class="font-semibold">Total</dt>
      <dd class="tabular-nums font-semibold">${total.toFixed(2)}</dd>
    </dl>

    <button
      type="submit"
      class="mt-2 self-start rounded bg-blue-700 px-4 py-1.5 text-sm font-medium text-white hover:bg-blue-800 disabled:cursor-not-allowed disabled:opacity-50"
      disabled={!isValid}
    >
      Place order
    </button>
  </form>
{/if}