JAVASCRIPT / PROJECTS AND PROFESSIONAL CRAFT
Project: a validated form with live feedback
Build a signup form whose validity lives in one pure function while blur and submit decide when each message becomes visible.
What you will learn
- Write validation as one pure values-to-errors function used by both typing and submit
- Track touched per field so messages appear on blur, not on the first keystroke
- Point each input at its message element with aria-describedby and toggle aria-invalid
- On a blocked submit, reveal every message and focus the first invalid input
Understanding Project: a validated form with live feedback
A form holds three separate things, and merging them is what turns form code into a tangle: the values, a pure function that maps values to error messages, and a second piece of state that decides which of those messages the user is allowed to see yet. Once fieldError(field, value) is the only code that knows what invalid means, the live feedback and the submit check cannot disagree, because both call it. Ordering the rules per field also gives you a natural rule for what to display: the first failing test, so a blank email says "Email is required" instead of complaining about a missing @.
Timing is the whole difference between helpful and hostile. Typing "a" into an email box is not an error, it is an unfinished thought, so the first reveal waits for blur or for a submit attempt; from then on every input event re-runs the same check so the message disappears the instant the value becomes acceptable. A touched flag per field plus one form-wide submitted flag is enough to encode that, and validity itself stays completely independent of both.
Treat the DOM as a projection of that state. A render step writes textContent into each field's message element and toggles aria-invalid on the input, and no decision is ever made by reading a class name or message text back out of the page. Each message element keeps a stable id that its input references with aria-describedby and sits in a container marked aria-live="polite", so a new message is announced rather than silently repainted. Put novalidate on the form so your text replaces the browser's bubble, while required and type="email" stay in the markup for semantics and the right mobile keyboard.
const rules = {
email: [
[v => v.trim().length > 0, 'Email is required'],
[v => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v), 'Use the form name@example.com']
],
password: [
[v => v.length >= 8, 'Use at least 8 characters'],
[v => /\d/.test(v), 'Include at least one digit']
]
};
const state = {
email: { value: 'ada@', touched: true },
password: { value: 'lovelace', touched: false }
};
// The only code that decides what counts as invalid: first failing rule wins.
function fieldError(field, value) {
for (const [passes, message] of rules[field]) {
if (!passes(value)) return message;
}
return '';
}
// The message exists as soon as the value is wrong; it is only shown
// after the user has left the field.
function shown(field) {
return state[field].touched ? fieldError(field, state[field].value) : '';
}
for (const field of Object.keys(rules)) {
console.log(field + ' shows: "' + shown(field) + '"');
}
const canSubmit = Object.keys(rules).every(f => fieldError(f, state[f].value) === '');
console.log('canSubmit: ' + canSubmit);
// A submit attempt marks every field touched, so nothing stays hidden.
Object.values(state).forEach(f => { f.touched = true; });
console.log('password now shows: "' + shown('password') + '"');Whether a field is invalid and whether its message is visible are two different pieces of state, and the DOM only renders them.
Worked examples
Reveal on blur, clear on input
The same check runs on every keystroke, but a touched flag decides whether its result reaches the page.
const input = document.createElement('input');
const hint = document.createElement('p');
hint.id = 'email-error';
input.setAttribute('aria-describedby', hint.id);
let touched = false;
function check() {
const bad = !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input.value);
const show = touched && bad;
hint.textContent = show ? 'Use the form name@example.com' : '';
input.setAttribute('aria-invalid', String(show));
}
input.addEventListener('input', check);
input.addEventListener('blur', () => { touched = true; check(); });
input.value = 'ada@';
input.dispatchEvent(new Event('input'));
console.log('typing -> hint: "' + hint.textContent + '", aria-invalid: ' + input.getAttribute('aria-invalid'));
input.dispatchEvent(new FocusEvent('blur'));
console.log('blur -> hint: "' + hint.textContent + '", aria-invalid: ' + input.getAttribute('aria-invalid'));
input.value = 'ada@example.com';
input.dispatchEvent(new Event('input'));
console.log('fixed -> hint: "' + hint.textContent + '", aria-invalid: ' + input.getAttribute('aria-invalid'));Example explained
Line 1The blur listener only flips touched; check() remains the single place that produces text, so there is no second copy of the rule.
Line 2After typing 'ada@' the value is already invalid, yet the hint stays empty because the user has not finished with the field.
Line 3Dispatching blur reveals the message and sets aria-invalid to true, which is what assistive tech reports alongside the text found through aria-describedby.
Line 4The final input event re-runs the same function and empties both the hint and the flag as soon as the value passes, so recovery is immediate.
Blocking a submit and pointing at the first bad field
Submit re-validates every field, returns an ordered summary, and names the input that should receive focus.
const order = ['name', 'email', 'password'];
const checks = {
name: v => v.trim() === '' ? 'Name is required' : '',
email: v => v.includes('@') ? '' : 'Email needs an @',
password: v => v.length >= 8 ? '' : 'Use at least 8 characters'
};
function submit(values) {
const failing = order
.map(field => [field, checks[field](values[field])])
.filter(([, message]) => message !== '');
if (failing.length === 0) return { sent: true, focus: null, summary: [] };
return {
sent: false,
focus: failing[0][0],
summary: failing.map(([field, message]) => field + ': ' + message)
};
}
const result = submit({ name: '', email: 'ada@example.com', password: 'abc' });
console.log('sent:', result.sent);
console.log('focus:', result.focus);
result.summary.forEach(line => console.log('- ' + line));Example explained
Line 1order lists the fields in the order they appear on the page, so failing[0] is exactly the input to focus after a blocked submit.
Line 2Each check returns '' when the value is fine, which collapses "valid" and "no message" into one value and removes a parallel boolean.
Line 3sent: false is what the real handler turns into event.preventDefault(); the decision comes from the rules, not from a disabled button.
Line 4summary feeds an error list at the top of the form, useful for long forms where the failing field is scrolled out of view.
Ignoring a stale async answer
When a field checks availability over the network, only the newest request may write feedback.
const taken = ['ada', 'grace'];
let latest = 0;
function checkName(name, replyMs) {
const ticket = ++latest;
setTimeout(() => {
if (ticket !== latest) {
console.log('ignored stale reply for "' + name + '"');
return;
}
console.log('"' + name + '" ' + (taken.includes(name) ? 'is taken' : 'is free'));
}, replyMs);
}
checkName('ada', 60); // slow answer for an old keystroke
checkName('adam', 10); // fast answer for what is in the box nowExample explained
Line 1Every call takes a ticket from latest, so a reply can tell whether it still describes the current value.
Line 2The reply for 'ada' arrives last because it was slower, but its ticket is stale and it is discarded instead of rendered.
Line 3Without the guard the field would end up reading "ada is taken" while the input contains 'adam', which is the classic live-feedback race.
Line 4Synchronous rules never need this; only checks that leave the page do.
Important notes
blur and focus do not bubble, so a single delegated listener on the form must listen for focusout (or attach with capture).
Client-side rules are a convenience, never a guarantee: the server has to repeat every check, and keep the email pattern loose so addresses like ada+news@example.co.uk are not rejected.
Common mistakes
Rendering the validator's output from the very first keystroke: typing 'a' immediately draws "Use the form name@example.com", so the form scolds people mid-word and they abandon it.
Deciding whether to submit by inspecting the page, such as looking for an .error class or non-empty message text: a CSS rename or an extra render silently lets invalid data through.
Using disabled on the submit button as the only feedback: nothing explains which field is wrong, and a keyboard or screen reader user reaches a dead control with no stated reason.
Try it yourself
Change, predict, then run
Add a "Confirm password" field to the two-field example, with a rule that compares its value against the password value. Check that editing the password re-runs the confirm field's check, and that submitting a mismatch reveals the message and focuses the confirm input.
Open the JavaScript workspaceCheck your understanding
A user types "a" into the email field and "Use the form name@example.com" appears at once. Which change stops the premature message while keeping instant feedback once the value is fixed?
- Show a field's message only after its first blur or a submit attempt, but keep re-running the check on every input event
- Move all validation into the submit handler and remove the input listener
- Debounce the input listener by 500ms so the message arrives later
- Disable the submit button whenever a field is invalid and delete the message element
Show answer
The fix is to separate "this value is invalid" from "the user may see the message": a touched flag delays only the reveal, while the check keeps running on input so the text clears the moment the value passes. Debouncing does not help, because "a" still triggers the same message half a second later; submit-only validation removes the premature message but also removes the instant confirmation that a correction worked.