Async Form Validation in React Is Hard — Here’s a Predictable Way to Solve It
Source: Dev.to
The core problem: validation is not deterministic
Most form solutions validate based on events, not state snapshots.
Example async validator
async function validateEmail(value: string) {
return api.checkEmailAvailability(value);
}
Imagine the user types quickly:
- Types
taken@example.com→ async request A starts - Changes to
john@example.com→ async request B starts - Request A finishes after request B ❌
The UI shows “Email already taken” (incorrect).
Older async results overwrite newer input—a classic race condition.
Problem #1: Async validation race conditions
Goal: only the latest validation result should matter.
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
- 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 often need to validate fields together, e.g.:
- Confirm password must match password
- End date must be after start date
- A field is required only if another field is enabled
Common approaches rely on watching other fields, manual re‑validation, or hidden dependencies, which introduces implicit behavior that’s hard to debug.
Explicit cross‑field validation
register("confirmPassword", {
validate: (value, values) =>
value !== values.password ? "Passwords do not match" : undefined,
});
- No magic
- No auto re‑validation
- No hidden dependencies – just readable logic.
Problem #3: Submit behaves differently than change
“Validation works on change, but submit behaves differently.”
This happens because many libraries:
- Only validate touched fields
- Skip untouched dependent fields on submit
Result: surprising submit‑time errors.
Simple rule to fix it
On submit, validate all registered fields. Always. This makes submit behavior predictable and correct.
The solution: a predictable, async‑first form engine
These problems motivated the creation of Formora, a headless React form engine built around 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 re‑validation
A real example using Formora
Async email validation + confirm password
const form = useForm({
initialValues: {
email: "",
password: "",
confirmPassword: "",
},
validateOn: "change",
asyncDebounceMs: 500,
});
{
await new Promise((r) => setTimeout(r, 300));
if (value.includes("taken")) return "Email already taken";
},
})}
/>
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 isn’t trying to replace every form library; it shines when you need:
- 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, or complex submit logic, Formora provides a solid, predictable foundation.
Final thoughts
Forms become difficult not because they are inherently 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:
- npm: