Theory

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.