[Guide] Build a Form Builder (Validation on Every Keystroke) - implementation notes
The Form Builder guide leans into constant validation and blur-as-default submission, plus async checks without cancellation. The framing is: if validation is a contract, do it continuously and make the lane visible, then use route-state stepping so the builder and preview stay coherent. I'm curious how this felt in practice.
How did you keep per-keystroke validation from feeling laggy or noisy for users? Did you make validation results stored-derived (per field + per step), or compute them live? How did you handle async validation without cancellation—did you accept out-of-order results or encode a version lane? Where did multi-truth inputs show up for you (draft schema vs committed schema)?
Comments (22)
Back to latestKeystroke validation works if you treat the UI as lanes: validating, valid, invalid.
If you hide it, users just think the form is yelling at them randomly.
We encoded async validation without cancellation as "versioned lanes":
ts
type FieldLane =
| { kind: 'idle' }
| { kind: 'validating'; v: number; startedAt: number }
| { kind: 'invalid'; v: number; message: string }
| { kind: 'valid'; v: number; checkedAt: number };
Then we log only contract transitions (React Tips style):
txt
[tips] field=email lane=validating v=7 reason=input:change
[tips] field=email lane=invalid v=7 message="missing @"Counterpoint: validating on every keystroke can feel hostile if errors show too early.
We delayed *display* of invalid messages until blur, but still computed the lane continuously.
Yes. Continuous validation doesn't have to mean continuous scolding.
Lane can be invalid while the UI stays neutral until the user commits an action boundary.
Stored-derived validation results were worth it because both builder and preview need the same shape.
If each panel recomputes, they'll disagree and it feels like a bug.
We kept schema drafts in a local doc (multi-truth) and committed to the vault only on blur or step change.
Otherwise every keystroke becomes "truth" and undo/redo gets weird.
Async without cancellation is survivable if you accept it and render evidence for it.
We show data-validation-v so out-of-order results are diagnosable from a screenshot.
We avoided lag by making validation a render effect that returns documentation strings.
tsx
useRenderEffect('validate:keystroke', () => {
const v = vault.bump('validationVersion');
vault.write('fieldLane', { kind: 'validating', v, startedAt: Date.now() });
return `field=email lane=validating v=${v}`;
});
And the log line we keep is exactly the returned string.
Route-state stepper was unexpectedly useful.
It stopped the "preview is a different app" feeling because back/forward replays the same steps.
Counterpoint: if validation is too strict too early, people can't explore the builder.
We kept some validations as warnings until publish time.
We did something similar: lane is still invalid, but the UI message is severity=warning until step 3.
The important part is making the severity a contract too.
We stored derived step completion so navigation is deterministic:
txt
[tips] step=fields completion=0.83 reason=derive:fields
[tips] step=preview completion=0.55 reason=derive:previewIf you keep validations as stored-derived, centralize derivation after any draft commit.
Otherwise you'll forget to re-derive on one path and the UI will contradict itself.
Blur submission made sense once we treated blur as "intent boundary".
Keystrokes are exploration; blur is commitment.
Docs tie-in: the forms posture + state posture pages basically imply this approach.
Related: Forms and Validation (Best Practices) and State Management (Best Practices).
We had to cap async checks by policy (max 1 per 500ms) or we'd hammer services. We log the gate decisions so it's debuggable:
txt
[tips] gate=asyncValidation allowed=false reason=debounce:cooldown remainingMs=312We also rendered data-last-validation-reason and it helped a ton in bug reports.
People would screenshot and you can tell if it validated because of typing, blur, or step change.
Counterpoint: constant validation can make the builder feel "busy".
We introduced a cool render tick posture for validation in low-power mode (less frequent derives).
That was our fix too. Keep the contract, adjust the cadence posture.
If cadence is explicit, it's not a bug—it's a mode.
The no-cancellation posture forced us to improve UI language.
We show "checking" with the current version and don't pretend results are instant.
We made validators pure and stored their outputs; side effects in validators made everything unpredictable.
If a validator fetches, treat it as a separate lane with logs.
A small win was separating "schema invalid" from "draft incomplete".
Both are non-publishable, but they feel different and need different UI language.
If you treat validation as documentation, keep the messages consistent across builder + preview.
Otherwise users think the preview is disagreeing with them.
We also stored derived "publish readiness" as a single string so support can debug it:
txt
[tips] readiness=blocked reason=field:email invalid message="missing @"This guide made me think of validation as a product contract, not an implementation detail.
Once you accept that, the logs and evidence become obvious rather than "extra".