Forms look simple at first: inputs, errors, submit.
But once you add async validation, things get messy very quickly.
If you’ve worked on real-world React apps, you’ve probably seen problems like:
- “Email already taken” appears after the user fixes it
- Confirm password validation behaves inconsistently
- Errors appear or disappear based on timing, not logic
- Submitting while async validation is running feels unpredictable
These are not UI issues.
They are state and validation correctness problems.
Let’s break down why this happens — and how to fix it.
The core problem: validation is not deterministic
Most form solutions validate based on events, not state snapshots.
Consider this common async validator:
async function validateEmail(value: string) {
return api.checkEmailAvailability(value);
}
Now imagine the user types quickly:
- User types
taken@example.com→ async request A starts - User changes it to
john@example.com→ async request B starts - Request A finishes after request B ❌
- UI shows: “Email already taken” (incorrect)
The problem is clear:
Older async results overwrite newer input.
This is called a race condition, and it’s one of the hardest problems in form validation.
Problem #1: Async validation race conditions
What we want is simple:
- Only the latest validation result should matter
But many form libraries:
- Don’t track async validation runs
- Don’t tie validation to a stable value snapshot
- Allow stale results to win
What a correct solution must do
A reliable form engine must:
- Track async validation attempts
- Ignore stale async results
- Always validate against a consistent snapshot of values
Without this, async validation will never be predictable.
Problem #2: Cross-field validation is fragile
Real forms don’t validate fields in isolation.
Examples:
- Confirm password must match password
- End date must be after start date
- A field is required only if another field is enabled
Many solutions rely on:
- Watching other fields
- Re-triggering validation manually
- Declaring hidden dependencies
This introduces implicit behavior that’s hard to debug.
A better approach: explicit cross-field validation
Here’s a clear, predictable example:
register("confirmPassword", {
validate: (value, values) =>
value !== values.password ? "Passwords do not match" : undefined,
});
- No magic
- No auto re-validation
- No hidden dependencies
Just logic you can read and reason about.
Problem #3: Submit behaves differently than change
Another common issue:
“Validation works on change, but submit behaves differently.”
This happens because many libraries:
- Only validate touched fields
- Skip untouched dependent fields on submit
The result: surprising submit-time errors.
A simple rule that fixes this
On submit, validate all registered fields.
Always.
This makes submit behavior predictable and correct.
The solution: a predictable, async-first form engine
These problems are why we built Formora.
Formora is a headless React form engine designed around a few strict principles:
- Validation timing is explicit (change | blur | submit)
- Async validation is race-condition safe
- Validation always runs against value snapshots
- Cross-field validation is explicit
- No automatic dependency revalidation
This leads to a predictable mental model.
A real example using Formora
Async email validation + confirm password
const form = useForm({
initialValues: {
email: "",
password: "",
confirmPassword: "",
},
validateOn: "change",
asyncDebounceMs: 500,
});
<input
{...form.register("email", {
required: "Email is required",
validateAsync: async (value) => {
await new Promise((r) => setTimeout(r, 300));
if (value.includes("taken")) return "Email already taken";
},
})}
/>
<input
{...form.register("confirmPassword", {
validate: (value, values) =>
value !== values.password ? "Passwords do not match" : undefined,
})}
/>
What this gives you:
- Async validation that never shows stale errors
- Explicit cross-field logic
- Predictable submit behavior
- Clean, debuggable code
Why other developers can benefit from Formora
Formora is not trying to replace every form library.
It is ideal if you care about:
- Correct async behavior
- Explicit validation logic
- Type-safe, predictable state
- Debuggable form behavior in real applications
If your app has:
- Async validation
- Cross-field rules
- Complex submit logic
Formora provides a solid, predictable foundation.
Final thoughts
Forms become difficult not because they are complex —
but because validation correctness is often ignored.
Async logic, relationships between fields, and real user behavior require predictability, not magic.
Formora was built to solve these problems explicitly and reliably.
Useful links
GitHub: https://github.com/narek-webdev/formora
npm: https://www.npmjs.com/package/formora
Top comments (1)
Thanks for reading! 👋
I wrote this article to focus on the problems around async and cross-field validation, not to replace any existing library.
If you’ve hit race conditions or unpredictable submit behavior in real-world forms, I’d love to hear your experience or feedback.