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.
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>
);
} 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> 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}