Cross-field validation, the lifecycle, and simulation
In lesson 11 each field was validated on its own. Real forms are more complex: rules depend on each other, checks take time, and a submit has its own "lifecycle".
Cross-field validation. Some rules depend on another field's value. In the contact form: pick "Bug report" and the message minimum rises from 20 to 50 characters — we demand more detail. This is possible because rules(values) receives all the values:
if (values.subject === 'bug') {
base.message = [required('Message'), minLength(50, 'A bug report'), maxLength(1000)];
}
The submit lifecycle. When a submit goes to the network (real or simulated), the button can't just "sit there":
disabled + "Sending…" → success (a success panel) or error (a toast) → restore the button
try/catch/finally fits perfectly: try — send, catch — the error, finally — always restore the button.
Async validation. "Is the username taken?" can't be answered locally; you have to ask the server. In register this runs on blur (in reality — a debounced request to /api/username-available).
And an honest note — important in itself. This app has no backend. The submit is simulated (fakeSend, fakeRegister). But everything else — the validation, the loading state, the error handling — is exactly what you'd write for a real endpoint. Only fakeSend() would change — a fetch instead of a setTimeout. I say this openly because it matters: you're learning the real pattern, not a toy.
And: the password is never stored. Not in any localStorage. Passwords live on a server, hashed — never in the browser.