DEV Community

Cover image for Async Form Validation in React Is Hard — Here’s a Predictable Way to Solve It
Narek Sargsyan
Narek Sargsyan

Posted on

Async Form Validation in React Is Hard — Here’s a Predictable Way to Solve It

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);
}
Enter fullscreen mode Exit fullscreen mode

Now imagine the user types quickly:

  1. User types taken@example.com → async request A starts
  2. User changes it to john@example.com → async request B starts
  3. Request A finishes after request B ❌
  4. 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,
});
Enter fullscreen mode Exit fullscreen mode
  • 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,
  })}
/>
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
narek_sargsyan_53af0cb366 profile image
Narek Sargsyan

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.