Best practices and patterns for building forms
Tags: pattern forms validation
This guide covers best practices and common patterns for building forms with the theme library.
Basic Form Structure
<script>
import { FormField, Input, Button, toastStore } from '@classic-homes/theme-svelte';
let formData = $state({
name: '',
email: '',
});
let errors = $state({});
let submitting = $state(false);
async function handleSubmit(e) {
e.preventDefault();
if (!validate()) return;
submitting = true;
try {
await submitForm(formData);
toastStore.success('Form submitted successfully');
} catch (error) {
toastStore.error('Failed to submit form');
} finally {
submitting = false;
}
}
function validate() {
errors = {};
if (!formData.name) errors.name = 'Name is required';
if (!formData.email) errors.email = 'Email is required';
return Object.keys(errors).length === 0;
}
</script>
<form onsubmit={handleSubmit} class="space-y-4">
<FormField id="name" label="Name" error={errors.name} required>
<Input id="name" bind:value={formData.name} error={!!errors.name} />
</FormField>
<FormField id="email" label="Email" error={errors.email} required>
<Input id="email" type="email" bind:value={formData.email} error={!!errors.email} />
</FormField>
<Button type="submit" disabled={submitting}>
{submitting ? 'Submitting...' : 'Submit'}
</Button>
</form>Form Layout Patterns
Single Column
<form class="max-w-md space-y-4">
<Input label="Field 1" />
<Input label="Field 2" />
<Input label="Field 3" />
</form>Two Column Grid
<form class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<Input label="First Name" />
<Input label="Last Name" />
</div>
<Input label="Email" type="email" />
<div class="grid grid-cols-2 gap-4">
<Input label="City" />
<Input label="State" />
</div>
</form>Responsive Grid
<form class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input label="First Name" />
<Input label="Last Name" />
</div>
<Input label="Email" type="email" />
</form>Validation Patterns
Real-time Validation
<script>
let email = $state('');
let emailError = $state('');
let emailTouched = $state(false);
$effect(() => {
if (!emailTouched) return;
if (!email) {
emailError = 'Email is required';
} else if (!email.includes('@')) {
emailError = 'Please enter a valid email';
} else {
emailError = '';
}
});
</script>
<Input
label="Email"
type="email"
bind:value={email}
error={emailError}
onblur={() => (emailTouched = true)}
/>Submit Validation
<script>
function validateForm() {
const errors = {};
if (!formData.name.trim()) {
errors.name = 'Name is required';
}
if (!formData.email.trim()) {
errors.email = 'Email is required';
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
errors.email = 'Invalid email format';
}
if (formData.password.length < 8) {
errors.password = 'Password must be at least 8 characters';
}
return errors;
}
function handleSubmit(e) {
e.preventDefault();
const validationErrors = validateForm();
if (Object.keys(validationErrors).length > 0) {
errors = validationErrors;
return;
}
// Submit form...
}
</script>Schema Validation (with Zod)
<script>
import { z } from 'zod';
const schema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email'),
age: z.number().min(18, 'Must be 18 or older'),
});
function validate(data) {
const result = schema.safeParse(data);
if (result.success) return {};
return result.error.flatten().fieldErrors;
}
</script>Form State Management
Controlled Form
<script>
let form = $state({
name: '',
email: '',
preferences: {
newsletter: false,
notifications: true,
},
});
</script>
<Input label="Name" bind:value={form.name} />
<Input label="Email" bind:value={form.email} />
<Checkbox label="Newsletter" bind:checked={form.preferences.newsletter} />
<Switch label="Notifications" bind:checked={form.preferences.notifications} />Reset Form
<script>
const initialState = {
name: '',
email: '',
};
let form = $state({ ...initialState });
function reset() {
form = { ...initialState };
errors = {};
}
</script>
<form>
<!-- fields -->
<div class="flex gap-2">
<Button type="submit">Submit</Button>
<Button type="button" variant="outline" onclick={reset}>Reset</Button>
</div>
</form>Dirty State Tracking
<script>
const initialState = { name: '', email: '' };
let form = $state({ ...initialState });
const isDirty = $derived(JSON.stringify(form) !== JSON.stringify(initialState));
</script>
<Button type="submit" disabled={!isDirty}>Save Changes</Button>Common Form Types
Login Form
<form class="space-y-4" onsubmit={handleLogin}>
<Input label="Email" type="email" bind:value={email} required autocomplete="email" />
<Input
label="Password"
type="password"
bind:value={password}
required
autocomplete="current-password"
/>
<div class="flex items-center justify-between">
<Checkbox label="Remember me" bind:checked={rememberMe} />
<a href="/forgot-password" class="text-sm text-primary"> Forgot password? </a>
</div>
<Button type="submit" class="w-full" disabled={loading}>
{loading ? 'Signing in...' : 'Sign In'}
</Button>
</form>Registration Form
<form class="space-y-4" onsubmit={handleRegister}>
<div class="grid grid-cols-2 gap-4">
<Input label="First Name" bind:value={firstName} required />
<Input label="Last Name" bind:value={lastName} required />
</div>
<Input label="Email" type="email" bind:value={email} required />
<Input label="Password" type="password" bind:value={password} required />
<Input
label="Confirm Password"
type="password"
bind:value={confirmPassword}
error={password !== confirmPassword ? 'Passwords do not match' : ''}
required
/>
<Checkbox required>
I agree to the <a href="/terms">Terms of Service</a>
</Checkbox>
<Button type="submit" class="w-full">Create Account</Button>
</form>Contact Form
<form class="space-y-4">
<Input label="Name" bind:value={name} required />
<Input label="Email" type="email" bind:value={email} required />
<Select label="Subject" bind:value={subject} required>
<option value="">Select a subject...</option>
<option value="sales">Sales Inquiry</option>
<option value="support">Technical Support</option>
<option value="other">Other</option>
</Select>
<Textarea label="Message" bind:value={message} rows={5} required />
<Button type="submit">Send Message</Button>
</form>Multi-Step Forms
<script>
let step = $state(1);
const totalSteps = 3;
function nextStep() {
if (validateStep(step)) {
step = Math.min(step + 1, totalSteps);
}
}
function prevStep() {
step = Math.max(step - 1, 1);
}
</script>
<div class="space-y-6">
<!-- Progress indicator -->
<div class="flex justify-between">
{#each Array(totalSteps) as _, i}
<div class={cn('h-2 flex-1 mx-1 rounded', i < step ? 'bg-primary' : 'bg-muted')} />
{/each}
</div>
<!-- Step content -->
{#if step === 1}
<div class="space-y-4">
<h2>Personal Information</h2>
<Input label="Name" bind:value={form.name} />
<Input label="Email" bind:value={form.email} />
</div>
{:else if step === 2}
<div class="space-y-4">
<h2>Address</h2>
<Input label="Street" bind:value={form.street} />
<Input label="City" bind:value={form.city} />
</div>
{:else}
<div class="space-y-4">
<h2>Review</h2>
<!-- Review content -->
</div>
{/if}
<!-- Navigation -->
<div class="flex justify-between">
<Button variant="outline" onclick={prevStep} disabled={step === 1}>Previous</Button>
{#if step < totalSteps}
<Button onclick={nextStep}>Next</Button>
{:else}
<Button onclick={handleSubmit}>Submit</Button>
{/if}
</div>
</div>Accessibility
- Always use
<label>or FormField for inputs - Include proper
autocompleteattributes - Mark required fields clearly
- Announce errors to screen readers
- Maintain logical tab order
Related
- Input - Text input component
- FormField - Field wrapper
- Validation - Validation utilities