Not every site needs React. Contact forms, quote calculators, admin widgets, filterable lists and “save without full reload” flows often ship faster — and run lighter — in well-structured vanilla JavaScript. This article is a field guide for business sites and small portals: progressive enhancement, clear state, and fetch calls that fail gracefully.
The goal is UX that feels instant without becoming a single-page app science project.
Start with HTML that works without JS
If the network is slow or a script errors, the form should still submit to the server the old way. That is progressive enhancement, not nostalgia.
<form id="contact-form" method="post" action="/contact.php" novalidate>
<label for="email">Email</label>
<input id="email" name="email" type="email" required autocomplete="email">
<label for="message">Message</label>
<textarea id="message" name="message" required minlength="10"></textarea>
<input type="hidden" name="csrf_token" value="...">
<button type="submit">Send message</button>
<p id="form-status" role="status" aria-live="polite"></p>
</form>
novalidate lets you control messaging in JS while keeping the same constraints server-side. Never trust only the browser.
Unobtrusive listeners and small modules
// contact-form.js
export function initContactForm(root = document) {
const form = root.querySelector('#contact-form');
if (!form) return;
form.addEventListener('submit', onSubmit);
}
async function onSubmit(event) {
const form = event.currentTarget;
const status = form.querySelector('#form-status');
const btn = form.querySelector('[type="submit"]');
if (!form.checkValidity()) {
form.reportValidity();
return;
}
event.preventDefault();
btn.disabled = true;
status.textContent = 'Sending…';
try {
const res = await fetch(form.action, {
method: 'POST',
body: new FormData(form),
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
credentials: 'same-origin',
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.ok) {
throw new Error(data.error || 'Something went wrong. Please try again.');
}
status.textContent = data.message || 'Thanks — we will reply soon.';
form.reset();
} catch (err) {
status.textContent = err.message || 'Network error.';
} finally {
btn.disabled = false;
}
}
Disable the button while in flight to prevent double posts. Re-enable in finally so errors do not trap the user.
Client checks are UX; server checks are security
Mirror important rules in JavaScript for instant feedback, but always validate again in PHP/Node/Python on the server. Example lightweight helpers:
function isEmail(value) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(value).trim());
}
function required(value) {
return String(value || '').trim().length > 0;
}
For currency, quantities and dates, prefer constrained inputs (type="number", min, step) plus server-side clamping.
Fetch patterns that do not lie
- Set
credentials: 'same-origin'when cookies/sessions matter - Send CSRF tokens for state-changing requests
- Handle non-JSON error pages (HTML 500s) without assuming
.json()works - Use timeouts via
AbortControlleron flaky mobile networks
export async function postJSON(url, body, { timeoutMs = 12000, headers = {} } = {}) {
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(url, {
method: 'POST',
credentials: 'same-origin',
signal: controller.signal,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
...headers,
},
body: JSON.stringify(body),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const err = new Error(data.error || res.statusText || 'Request failed');
err.status = res.status;
err.data = data;
throw err;
}
return data;
} finally {
clearTimeout(t);
}
}
Optimistic UI — only when rollback is easy
Toggling a “favourite” star can feel instant if you flip the UI first and revert on failure. Submitting an invoice payment should not be optimistic. Match the pattern to the cost of being wrong.
starBtn.addEventListener('click', async () => {
const wasOn = starBtn.classList.contains('is-on');
starBtn.classList.toggle('is-on', !wasOn);
try {
await postJSON('/api/favourite', { id: starBtn.dataset.id, on: !wasOn });
} catch {
starBtn.classList.toggle('is-on', wasOn); // rollback
toast('Could not save — try again', true);
}
});
Toasts, focus and accessibility
- Use
aria-live="polite"for status text - Move focus to the first error field on failed validation
- Do not rely on colour alone for success/error
- Keep keyboard users in mind for custom selects and modals
function focusFirstInvalid(form) {
const el = form.querySelector(':invalid');
if (el && typeof el.focus === 'function') el.focus();
}
For modals: trap focus, close on Escape, restore focus to the opener button.
Debounce search and resize handlers
function debounce(fn, ms = 200) {
let id;
return (...args) => {
clearTimeout(id);
id = setTimeout(() => fn(...args), ms);
};
}
const (q) => {
const box = document.querySelector('#results');
box.textContent = 'Searching…';
const res = await fetch('/api/search?q=' + encodeURIComponent(q));
const data = await res.json();
box.innerHTML = data.html || '<p>No matches.</p>';
}, 180);
document.querySelector('#q')?.addEventListener('input', (e) => {
onSearch(e.target.value.trim());
});
If you inject HTML from the server, ensure that HTML is escaped server-side. Never build HTML from raw user strings on the client without escaping.
Structure that scales past one file
- One concern per module (
forms.js,nav.js,toast.js) - ES modules when the build allows; otherwise a single IIFE bundle with namespaces
- No inline
onclick=in HTML for new work - Feature-detect only when necessary; prefer baseline APIs
When a framework is worth it
Complex client state, offline-first apps, multi-route dashboards with shared caches — frameworks earn their keep. A five-field contact form does not need a virtual DOM. Choose tools proportional to the problem.
Event delegation for lists that grow
If you render twenty invoice rows and bind a click listener to each button, you will forget to re-bind after AJAX refresh. Delegation keeps one listener on a stable parent:
document.querySelector('#invoice-table')?.addEventListener('click', async (e) => {
const btn = e.target.closest('[data-action="mark-paid"]');
if (!btn) return;
e.preventDefault();
const id = btn.getAttribute('data-id');
btn.disabled = true;
try {
await postJSON('/admin/invoices/mark-paid', { id, csrf_token: window.CSRF });
btn.closest('tr')?.classList.add('is-paid');
toast('Marked paid');
} catch (err) {
toast(err.message || 'Could not update', true);
} finally {
btn.disabled = false;
}
});
File uploads without the drama
const input = document.querySelector('#logo');
input?.addEventListener('change', () => {
const file = input.files?.[0];
const hint = document.querySelector('#logo-hint');
if (!file) return;
const max = 2 * 1024 * 1024; // 2MB
if (!file.type.startsWith('image/')) {
hint.textContent = 'Please choose an image file.';
input.value = '';
return;
}
if (file.size > max) {
hint.textContent = 'Image must be under 2MB.';
input.value = '';
return;
}
hint.textContent = `Ready: ${file.name}`;
});
Still enforce type and size on the server. Client checks only improve UX.
A tiny toast helper
export function toast(message, isError = false) {
let el = document.querySelector('#app-toast');
if (!el) {
el = document.createElement('div');
el.id = 'app-toast';
el.setAttribute('role', 'status');
el.setAttribute('aria-live', 'polite');
document.body.appendChild(el);
}
el.textContent = message;
el.classList.toggle('is-error', !!isError);
el.classList.add('is-on');
clearTimeout(toast._t);
toast._t = setTimeout(() => el.classList.remove('is-on'), 2400);
}
Testing the boring paths
- Submit empty form — errors appear and focus moves
- Submit valid form offline — honest network message
- Double-click submit — only one request fires
- Keyboard-only path through the form
- Server returns 422 with field errors — messages map correctly
Ten minutes of manual QA here prevents “the form just sits there” support emails for months.
The best front-end code is the code that makes the next change safe — not the code that demos well on Twitter.
Need interactive forms or a small portal UI?
I build custom PHP/WordPress front ends with tidy fast, accessible and maintainable.