Real-World Examples and Best Practices
Let's see reactive forms solving real problems you'll encounter in web development.
Example 1: Login Form
The scenario
A simple login with email and password validation.
const loginForm = Forms.create(
{ email: '', password: '' },
{
validators: {
email: Forms.v.combine(
Forms.v.required('Email is required'),
Forms.v.email('Please enter a valid email')
),
password: Forms.v.required('Password is required')
},
onSubmit: async (values) => {
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(values)
});
return response.json();
}
}
);
// Reactive UI
effect(() => {
Elements.loginBtn.update({ disabled: !loginForm.isValid || loginForm.isSubmitting });
Elements.loginBtn.update({ textContent: loginForm.isSubmitting ? 'Logging in...' : 'Log In' });
});
// Handle submission
async function handleLogin() {
const result = await loginForm.submit();
if (result.success) {
window.location.href = '/dashboard';
} else if (result.errors) {
console.log('Validation failed');
}
}Key patterns: Async submit, button disabled state, loading text
Example 2: Registration Form with Password Confirmation
The scenario
A registration form where the password confirmation must match.
const registerForm = Forms.create(
{ username: '', email: '', password: '', confirmPassword: '' },
{
validators: {
username: Forms.v.combine(
Forms.v.required('Username is required'),
Forms.v.minLength(3, 'At least 3 characters'),
Forms.v.pattern(/^[a-zA-Z0-9_]+$/, 'Only letters, numbers, underscores')
),
email: Forms.v.combine(
Forms.v.required('Email is required'),
Forms.v.email('Invalid email address')
),
password: Forms.v.combine(
Forms.v.required('Password is required'),
Forms.v.minLength(8, 'At least 8 characters')
),
confirmPassword: Forms.v.combine(
Forms.v.required('Please confirm your password'),
Forms.v.match('password', 'Passwords do not match')
)
},
onSubmit: async (values) => {
const response = await fetch('/api/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: values.username,
email: values.email,
password: values.password
})
});
return response.json();
}
}
);Key patterns: match() for password confirmation, excluding confirmPassword from submission
Example 3: Contact Form with Reset
The scenario
A contact form that resets after successful submission.
const contactForm = Forms.create(
{ name: '', email: '', subject: '', message: '' },
{
validators: {
name: Forms.v.required('Please enter your name'),
email: Forms.v.combine(
Forms.v.required('Email is required'),
Forms.v.email('Invalid email')
),
message: Forms.v.combine(
Forms.v.required('Please write a message'),
Forms.v.minLength(20, 'Please write at least 20 characters')
)
},
onSubmit: async (values) => {
await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(values)
});
}
}
);
async function sendMessage() {
const result = await contactForm.submit();
if (result.success) {
alert('Message sent!');
contactForm.reset(); // Clear the form after success
}
}Key patterns: .reset() after successful submission, optional subject (no validator)
Example 4: Edit Profile with Pre-filled Data
The scenario
An edit form that loads existing data and only submits if something changed.
// Pre-fill from existing user data
const profileForm = Forms.create(
{ name: 'Alice', email: 'alice@example.com', bio: 'Hello world' },
{
validators: {
name: Forms.v.required('Name is required'),
email: Forms.v.combine(
Forms.v.required('Email is required'),
Forms.v.email('Invalid email')
),
bio: Forms.v.maxLength(200, 'Bio must be 200 characters or less')
},
onSubmit: async (values) => {
await fetch('/api/profile', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(values)
});
}
}
);
// Show save button only if the form has been modified
effect(() => {
Elements.saveBtn.update({ hidden: !profileForm.isDirty });
});Key patterns: Pre-filled initial values, .isDirty to show/hide save button
Example 5: Multi-Step Form (Wizard)
The scenario
A form split into steps, where each step validates independently.
const wizardForm = Forms.create(
{
// Step 1
firstName: '', lastName: '',
// Step 2
email: '', phone: '',
// Step 3
agree: false
},
{
validators: {
firstName: Forms.v.required('First name is required'),
lastName: Forms.v.required('Last name is required'),
email: Forms.v.combine(
Forms.v.required('Email is required'),
Forms.v.email('Invalid email')
),
agree: Forms.v.custom((value) => {
if (!value) return 'You must agree to the terms';
return null;
})
}
}
);
const step = state({ current: 1 });
// Step-specific field groups
const stepFields = {
1: ['firstName', 'lastName'],
2: ['email', 'phone'],
3: ['agree']
};
function validateStep() {
const fields = stepFields[step.current];
let valid = true;
fields.forEach(field => {
wizardForm.setTouched(field);
if (!wizardForm.validateField(field)) {
valid = false;
}
});
return valid;
}
function nextStep() {
if (validateStep()) {
step.current++;
}
}
function prevStep() {
step.current--;
}
async function finish() {
if (validateStep()) {
const result = await wizardForm.submit(async (values) => {
return await fetch('/api/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(values)
});
});
}
}Key patterns: .validateField() for step-specific validation, .setTouched() per field, custom submit handler
Example 6: Server-Side Error Handling
The scenario
Showing errors that come back from the server.
const form = Forms.create(
{ email: '', username: '' },
{
validators: {
email: Forms.v.combine(
Forms.v.required('Email is required'),
Forms.v.email('Invalid email')
),
username: Forms.v.required('Username is required')
},
onSubmit: async (values, form) => {
const response = await fetch('/api/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(values)
});
const data = await response.json();
if (!response.ok) {
// Set server-side errors on specific fields
if (data.errors) {
form.setErrors(data.errors);
}
throw new Error(data.message || 'Registration failed');
}
return data;
}
}
);
// The effect shows errors regardless of where they came from
effect(() => {
['email', 'username'].forEach(field => {
const showError = form.shouldShowError(field);
Id(`${field}Error`).update({
hidden: !showError,
textContent: showError ? form.getError(field) : ''
});
});
});Key patterns: .setErrors() for server-side errors, same error display logic for client and server errors
Example 7: Dynamic Character Counter
The scenario
A textarea with a live character count and max length validation.
const form = Forms.create(
{ bio: '' },
{
validators: {
bio: Forms.v.maxLength(280, 'Bio must be 280 characters or less')
}
}
);
effect(() => {
const length = (form.values.bio || '').length;
Elements.charCount.update({
textContent: `${length} / 280`,
style: { color: length > 280 ? 'red' : 'gray' }
});
});Key patterns: Reading .values.bio.length inside an effect for live updates
Example 8: Form State Debugging
The scenario
Displaying the current form state for development.
const form = Forms.create(
{ name: '', email: '' },
{
validators: {
name: Forms.v.required('Required'),
email: Forms.v.email('Invalid')
}
}
);
effect(() => {
Elements.debugPanel.update({ textContent: JSON.stringify(form.toObject(), null, 2) });
});Key patterns: .toObject() for a plain snapshot, useful for development
Best Practices
✅ Use .shouldShowError() for error display
// ✅ Only shows error after user interaction
if (form.shouldShowError('email')) {
showError(form.getError('email'));
}
// ❌ Shows error immediately, even on a blank form
if (form.hasError('email')) {
showError(form.getError('email'));
}✅ Use .setValues() for bulk updates
// ✅ Batched — effects run once
form.setValues({ name: 'Alice', email: 'alice@example.com' });
// Less efficient — effects run twice
form.setValue('name', 'Alice');
form.setValue('email', 'alice@example.com');✅ Use combine() for multi-rule validation
// ✅ Clean and composable
validators: {
password: Forms.v.combine(
Forms.v.required('Required'),
Forms.v.minLength(8, 'Too short'),
Forms.v.pattern(/[A-Z]/, 'Needs an uppercase letter')
)
}✅ Use .reset() after successful submission
const result = await form.submit();
if (result.success) {
form.reset(); // ✅ Clean slate for next submission
}✅ Use computed properties for UI state
// ✅ Reactive — updates automatically
effect(() => {
Elements.submitBtn.update({
disabled: !form.isValid || form.isSubmitting,
textContent: form.isSubmitting ? 'Saving...' : 'Save'
});
});❌ Don't write validators that return true/false
// ❌ Wrong — returns boolean
validators: {
name: (value) => value.length > 0
}
// ✅ Correct — returns null or error string
validators: {
name: (value) => {
if (!value) return 'Name is required';
return null;
}
}❌ Don't forget to await .submit()
// ❌ result is a Promise, not the actual result
const result = form.submit();
// ✅ Await the Promise
const result = await form.submit();❌ Don't modify .values directly when you need validation
// ❌ Bypasses auto-validation and touched tracking
form.values.email = 'test@test.com';
// ✅ Use setValue — it validates and marks as touched
form.setValue('email', 'test@test.com');Key Takeaways
- Use
.shouldShowError()to show errors only after user interaction - Use
.setValues()for bulk updates (batched for performance) - Use
combine()to chain multiple validation rules - Use
.reset()after successful submissions - Always await
.submit()— it returns a Promise - Use
.setValue()instead of modifying.valuesdirectly for auto-validation - Computed properties (
.isValid,.isDirty,.isSubmitting) are your best friends for UI state
What's next?
Let's explore DOM binding, event handling, and the complete API reference.
Let's continue!