Real-World Patterns and Best Practices
Pattern 1: The Complete Login Form
A full login flow with validation, submission, error handling, and redirect:
<form id="loginForm">
<div>
<label>Email</label>
<input name="email" type="email" required />
</div>
<div>
<label>Password</label>
<input name="password" type="password" required minlength="6" />
</div>
<button type="submit">Log In</button>
</form>
<script>
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const result = await Forms.loginForm.submitData({
url: '/api/auth/login',
validationRules: {
email: { required: true, email: true },
password: { required: true, minLength: 6 }
},
onSuccess: (response) => {
localStorage.setItem('token', response.token);
window.location.href = '/dashboard';
},
onError: (error, validationErrors) => {
if (validationErrors) return; // Already shown in DOM
// Show general server error
document.getElementById('loginError').style.display = 'block';
}
});
});
</script>Pattern 2: Declarative Contact Form (No JavaScript)
Using data-enhanced to set up the entire submission pipeline in HTML:
<form
id="contactForm"
data-enhanced
data-submit-url="/api/contact"
data-success-message="Thanks! We'll be in touch within 24 hours."
data-reset-on-success
data-message-position="start"
>
<input name="name" required placeholder="Your name" />
<input name="email" type="email" required placeholder="Email" />
<textarea name="message" required minlength="20"
placeholder="Your message (at least 20 characters)"></textarea>
<button type="submit">Send Message</button>
</form>
<!-- No JavaScript needed! The module handles everything. -->Pattern 3: Multi-Step Form with Draft Saving
const steps = ['stepOne', 'stepTwo', 'stepThree'];
let currentStep = 0;
const formData = {};
function saveCurrentStep() {
// Save this step's values into our accumulator
Object.assign(formData, Forms[steps[currentStep]].values);
// Also persist to localStorage as a draft
localStorage.setItem('formDraft', JSON.stringify(formData));
}
function nextStep() {
const form = Forms[steps[currentStep]];
const { isValid } = form.validate();
if (!isValid) return; // Don't advance if current step has errors
saveCurrentStep();
currentStep++;
// Show next step's form, hide current...
}
async function submitAll() {
saveCurrentStep(); // Save last step
const result = await fetch('/api/application', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData)
}).then(r => r.json());
if (result.success) {
localStorage.removeItem('formDraft');
window.location.href = '/confirmation';
}
}
// Restore draft on page load
const draft = localStorage.getItem('formDraft');
if (draft) {
const saved = JSON.parse(draft);
// Pre-fill the first step from the draft
Forms[steps[0]].values = saved;
}Pattern 4: Inline Form Editing
Let users edit data inline on a profile page:
let isEditing = false;
const originalValues = {};
function startEdit() {
if (isEditing) return;
isEditing = true;
// Save the current values so we can cancel
Object.assign(originalValues, Forms.profileForm.values);
// Show editable inputs, hide display text
document.querySelectorAll('.display-value').forEach(el => el.style.display = 'none');
document.querySelectorAll('.edit-input').forEach(el => el.style.display = 'block');
}
function cancelEdit() {
// Restore original values
Forms.profileForm.values = originalValues;
Forms.profileForm.clearValidation();
isEditing = false;
document.querySelectorAll('.display-value').forEach(el => el.style.display = 'block');
document.querySelectorAll('.edit-input').forEach(el => el.style.display = 'none');
}
async function saveEdit() {
const { isValid } = Forms.profileForm.validate({
displayName: { required: true, minLength: 2 },
email: { required: true, email: true }
});
if (!isValid) return;
const result = await Forms.profileForm.submitData({
url: '/api/profile',
method: 'PUT'
});
if (result.success) {
isEditing = false;
// Update display values from the saved data
Object.entries(result.data).forEach(([key, value]) => {
const display = document.querySelector(`.display-value[data-field="${key}"]`);
if (display) display.textContent = value;
});
}
}Pattern 5: Search Form with Debounced Input
Using the Async module's debounceInput on a search form (requires 09_async to be loaded):
// Attach a debounced search handler
Forms.searchForm.debounceInput('[name="q"]', async (e) => {
const query = e.target.value.trim();
if (!query) return;
const results = await AsyncHelpers.fetchJSON(`/api/search?q=${encodeURIComponent(query)}`);
renderResults(results);
}, 400); // wait 400ms after user stops typing
// Sanitize input before searching (also from 09_async)
Forms.searchForm.debounceInput('[name="q"]', async (e) => {
const clean = AsyncHelpers.sanitize(e.target.value);
// ... use clean query
}, 400);Pattern 6: Dynamic Form with Plugin (addEnhancer)
Extend every form with a custom method using addEnhancer:
// Register a plugin that adds a .isDirty() method to every form
Forms.addEnhancer(function(form) {
// Store initial values when form is first enhanced
const initialValues = JSON.stringify(form.values);
form.isDirty = function() {
return JSON.stringify(form.values) !== initialValues;
};
form.resetDirtyTracking = function() {
// Update the baseline to current values
Object.assign(initialValues, form.values);
};
return form;
});
// Now every form has .isDirty()
window.addEventListener('beforeunload', (e) => {
if (Forms.articleForm.isDirty()) {
e.preventDefault();
e.returnValue = 'You have unsaved changes. Are you sure you want to leave?';
}
});Pattern 7: Server-Side Validation Error Display
When the server returns field-level errors, map them back to the form:
await Forms.registrationForm.submitData({
url: '/api/register',
validate: false, // we'll handle all validation on the server
onError: (error) => {
// If the server returns { fieldErrors: { email: 'Already in use' } }
const serverErrors = error.fieldErrors;
if (serverErrors) {
// Manually trigger validation display using custom rules
Forms.registrationForm.validate(
Object.fromEntries(
Object.entries(serverErrors).map(([field, message]) => [
field,
() => message // return the server message as the error
])
)
);
} else {
// Generic error
document.getElementById('submitError').textContent = error.message;
}
}
});Best Practices
1. Always Use id Attributes on Your Forms
The Forms proxy looks up forms by their id. Without an id, you can't use Forms.myForm:
<!-- ✅ Has an id — accessible via Forms.loginForm -->
<form id="loginForm">...</form>
<!-- ❌ No id — not accessible via Forms proxy -->
<form>...</form>2. Access Forms After DOMContentLoaded
// ✅ Safe — DOM is ready
document.addEventListener('DOMContentLoaded', () => {
const form = Forms.loginForm;
});
// ✅ Also safe — inside an event handler called after page load
document.getElementById('openBtn').addEventListener('click', () => {
Forms.modalForm.reset();
});
// ⚠️ Be careful at module top-level — form may not exist yet
const form = Forms.loginForm; // could be null if DOM not ready3. Let the Module Handle the Full Pipeline
Resist the urge to manually wire fetch — let .submitData() or the enhanced pipeline handle it:
// ✅ Let the module handle it
await Forms.contactForm.submitData({ url: '/api/contact', ... });
// ❌ Manual wiring — misses validation, error handling, hooks
fetch('/api/contact', {
method: 'POST',
body: JSON.stringify(Forms.contactForm.values)
});4. Use .clearValidation() Before Programmatic Submissions
When you're controlling submission outside of a user-click (like a timer auto-save), clear validation first to avoid stale error states:
async function autoSave() {
Forms.draftForm.clearValidation(); // clear any previous errors
await Forms.draftForm.submitData({
url: '/api/drafts',
validate: false // auto-save — don't require valid data
});
}5. Prefer data-enhanced for Simple Forms
If your form just needs to POST to a URL and show a success message, the declarative approach is cleaner than writing JavaScript:
<!-- ✅ Simple and clean -->
<form data-enhanced data-submit-url="/api/subscribe"
data-success-message="Subscribed!" data-reset-on-success>
<input name="email" type="email" required />
<button type="submit">Subscribe</button>
</form>6. Handle the cancelled Case
When beforeSubmit returns false, the result has cancelled: true — not success: false with an error:
const result = await Forms.myForm.submitData({
beforeSubmit: async () => {
if (!userConfirmed) return false;
}
});
if (result.cancelled) {
console.log('User cancelled — do nothing');
} else if (!result.success) {
console.log('Real error:', result.error);
}Quick Reference: The Entire Forms API
Forms global:
Forms.formId → get/enhance a form by ID
Forms.getAllForms() → all forms on the page
Forms.validateAll(rules) → validate every form
Forms.resetAll() → reset every form
Forms.addEnhancer(fn) → register a plugin
Forms.stats() → cache statistics
Forms.clear() → clear the cache
Forms.configure(opts) → update module options
Per-form (Forms.formId):
.values → get/set all values
.getField(name) → get field element
.setField(name, val, opts) → set one field
.validate(rules) → validate + show errors
.clearValidation() → remove error state
.serialize(format) → object|json|formdata|urlencoded
.submitData(options) → async fetch submission
.reset(options) → reset + clear validation
.update(object) → form-aware update method
.configure(opts) → per-form config (with enhancement module)
.connectReactive(form,opts)→ two-way reactive bridge (with enhancement module)
FormEnhancements global:
FormEnhancements.submit(form, opts) → enhanced submission
FormEnhancements.configure(opts) → global config
FormEnhancements.validators → validator factories
Forms.validators / Forms.v → same validator factories