Form handling is one of the most duplicated pieces of code in web applications. Every form seems to need its own event listener, validation logic, data collection, and submission handler. But what if you could write this logic once and reuse it everywhere?
This tutorial demonstrates the DRY (Don't Repeat Yourself) principle in action by building a single, reusable form handler utility that works with any form structure.
The Problem: Duplicate Form Handlers
Here's what typical form handling code looks like before refactoring:
// Contact form handler (35 lines)
const contactForm = document.getElementById('contact-form');
contactForm.addEventListener('submit', function(e) {
e.preventDefault();
const name = document.getElementById('name').value;
const email = document.getElementById('email').value;
const message = document.getElementById('message').value;
const body = `Name: ${name}%0D%0AEmail: ${email}%0D%0AMessage: ${message}`;
const mailtoUrl = `mailto:info@example.com?subject=Contact Form&body=${body}`;
window.location.href = mailtoUrl;
});
// Assessment form handler (42 lines)
const assessmentForm = document.getElementById('assessment-form');
assessmentForm.addEventListener('submit', function(e) {
e.preventDefault();
const companyName = document.getElementById('company-name').value;
const projectType = document.getElementById('project-type').value;
const timeline = document.getElementById('timeline').value;
const budget = document.getElementById('budget').value;
const body = `Company: ${companyName}%0D%0AProject Type: ${projectType}%0D%0ATimeline: ${timeline}%0D%0ABudget: ${budget}`;
const mailtoUrl = `mailto:info@example.com?subject=Free Assessment&body=${body}`;
window.location.href = mailtoUrl;
});
// More forms = more duplicate code...
Notice the pattern? We're writing the same structure repeatedly: get form, prevent default, collect field values, build mailto URL, redirect. This violates the DRY principle and makes maintenance difficult.
The Solution: A Reusable Utility
Let's build a single utility function that handles any form:
// utilities/form-handler.js (28 lines)
export function createMailtoHandler(formId, fieldIds, subject, recipient = 'info@example.com') {
const form = document.getElementById(formId);
if (!form) return;
form.addEventListener('submit', function(e) {
e.preventDefault();
// Collect data from specified fields
const data = {};
Object.entries(fieldIds).forEach(([label, id]) => {
const element = document.getElementById(id);
data[label] = element ? element.value : '';
});
// Build mailto body
const bodyParts = Object.entries(data)
.map(([label, value]) => `${label}: ${value || 'N/A'}`)
.join('%0D%0A');
// Construct and trigger mailto URL
const mailtoUrl = `mailto:${recipient}?subject=${encodeURIComponent(subject)}&body=${bodyParts}`;
window.location.href = mailtoUrl;
});
}
How It Works
This utility function accepts four parameters:
- formId: The ID of the form element
- fieldIds: An object mapping labels to field IDs
- subject: Email subject line
- recipient: Email recipient (optional, defaults to info@example.com)
Using the Reusable Handler
Now we can replace all that duplicate code with simple builder functions:
// builders/contact-form.js (24 lines)
import { createMailtoHandler } from '../utilities/form-handler.js';
export function initContactForm() {
createMailtoHandler(
'contact-form',
{
'Name': 'name',
'Email': 'email',
'Phone': 'phone',
'Message': 'message'
},
'Contact Form Submission',
'christo@yellowarcher.co.za'
);
}
// builders/assessment-form.js (18 lines)
import { createMailtoHandler } from '../utilities/form-handler.js';
export function initAssessmentForm() {
createMailtoHandler(
'assessment-form',
{
'Company Name': 'company-name',
'Project Type': 'project-type',
'Current Challenge': 'current-challenge',
'Timeline': 'timeline',
'Budget Range': 'budget'
},
'Free Assessment Request',
'christo@yellowarcher.co.za'
);
}
The Benefits
1. Massive Code Reduction
Before: 77 lines of duplicate form handling code
After: 28 lines of reusable utility + 42 lines of builder configurations = 70 lines total
And it handles unlimited forms!
2. Single Source of Truth
Need to change how forms submit? Update one utility function instead of hunting through multiple form handlers.
3. Easier Testing
// test/form-handler.test.js
import { createMailtoHandler } from '../utilities/form-handler.js';
describe('Form Handler', () => {
it('should collect data from specified fields', () => {
// Test the single utility function
// All forms automatically inherit this behavior
});
});
4. Consistent Behavior
Every form on your site behaves identically. No edge cases where one form works slightly differently.
Real-World Usage
Here's the HTML for a form using our reusable handler:
<form id="contact-form">
<input type="text" id="name" placeholder="Your Name" required>
<input type="email" id="email" placeholder="Your Email" required>
<input type="tel" id="phone" placeholder="Phone Number">
<textarea id="message" placeholder="Your Message" required></textarea>
<button type="submit">Send Message</button>
</form>
<script type="module">
import { initContactForm } from './builders/contact-form.js';
initContactForm();
</script>
Extending the Pattern
Once you have a reusable form handler, you can easily add features that benefit all forms:
Add Validation
// utilities/form-handler.js (with validation)
export function createMailtoHandler(formId, fieldIds, subject, recipient, validators = {}) {
const form = document.getElementById(formId);
if (!form) return;
form.addEventListener('submit', function(e) {
e.preventDefault();
const data = {};
let valid = true;
Object.entries(fieldIds).forEach(([label, id]) => {
const element = document.getElementById(id);
const value = element ? element.value : '';
// Run validator if provided
if (validators[id] && !validators[id](value)) {
valid = false;
element.classList.add('error');
}
data[label] = value;
});
if (!valid) return;
// ... rest of submission logic
});
}
Add Loading States
function handleSubmit() {
const submitBtn = form.querySelector('button[type="submit"]');
submitBtn.disabled = true;
submitBtn.textContent = 'Sending...';
// ... submission logic
setTimeout(() => {
submitBtn.disabled = false;
submitBtn.textContent = 'Send Message';
}, 2000);
}
Key Takeaways
- Identify patterns: Look for code that's written multiple times with slight variations
- Extract to utilities: Create a single function that handles the common pattern
- Parameterize differences: Use function parameters to handle variations between instances
- Keep blocks small: Our form handler is 28 lines—under the 30-line LEGO limit
- Test once, benefit everywhere: Fix bugs or add features in one place
Try It Yourself
Challenge: Find three functions in your codebase that share similar structure. Extract the common pattern into a reusable utility. You'll likely reduce 100+ lines of code to a single 20-30 line utility plus simple builder functions.
This is the essence of the DRY principle and LEGO Builder architecture: write reusable blocks once, compose them infinitely.
← Back to Blog