DEV Community

Alberto Barrago
Alberto Barrago

Posted on

Fix: Eliminating Double Async Validation in TanStack Form & Zod

A practical pattern to prevent duplicate API calls and race conditions
in complex React forms.

When building production-grade forms with TanStack Form and Zod,
especially in flows involving side effects (e.g., OTP generation,
user verification), you may encounter an elusive bug:

⚠️ Async validation running twice on submit

This can lead to duplicated API calls, inconsistent state, and poor user
experience.

In this article, we explore: - Why this happens - How to fix it
reliably - How to harden your form logic for real-world scale


🚨 The Problem: Double Async Execution

A known issue in TanStack Issue #1431 causes async
validation (superRefine) to execute multiple times during submission.

Typical Setup

const form = useForm({
  defaultValues: { ... },
  validators: {
    onChange: myZodSchema, // async superRefine
  },
  onSubmit: async ({ value }) => {
    await sendOtp(value); // ❌ may be called twice
  }
});
Enter fullscreen mode Exit fullscreen mode

Why It's Dangerous

In flows like OTP authentication: - Multiple requests generate
different codes - First code becomes invalid - Users get stuck

πŸ‘‰ This is not just inefficiency --- it's a critical UX bug


🧠 Root Cause

  • TanStack Form may trigger validation multiple times internally
  • superRefine contains side effects
  • Validation β‰  Pure function anymore

πŸ‘‰ This breaks the expectation that validation is idempotent


βœ… The Solution: Take Back Control

We fix the issue with 3 architectural decisions:


1. Manual Validation with safeParseAsync

Avoid relying on automatic validation during submission.

const result = await myZodSchema.safeParseAsync(form.state.values);
Enter fullscreen mode Exit fullscreen mode

βœ” Prevents double execution
βœ” Gives full control over validation lifecycle


2. Prevent Re-entrancy with useRef

React state is not always fast enough to block rapid interactions.

Use a low-level semaphore:

const isSubmittingRef = useRef(false);
Enter fullscreen mode Exit fullscreen mode

3. Decouple Side Effects

Never trigger API calls inside validation.

πŸ‘‰ Validation must remain pure
πŸ‘‰ Side effects go inside controlled submit flow


🧩 Full Implementation

const isSubmittingRef = useRef(false);

const handleSubmit = async () => {
  if (isSubmittingRef.current) return;

  isSubmittingRef.current = true;

  try {
    // 1. Manual validation
    const result = await myZodSchema.safeParseAsync(form.state.values);

    if (!result.success) {
      // map errors if needed
      return;
    }

    // 2. Execute side effect ONCE
    await triggerOtpRequest(result.data);

  } finally {
    isSubmittingRef.current = false;
  }
};
Enter fullscreen mode Exit fullscreen mode

🌐 Network Layer Optimization

During testing, another issue emerged: πŸ‘‰ unwanted re-fetching
disrupting UX

Fix your QueryClient config:

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 5 * 60 * 1000,
      refetchOnWindowFocus: false,
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

Why it matters

  • Users switch tabs to check OTP
  • Returning triggers refetch
  • UI state resets unexpectedly

πŸ‘‰ Disable it for critical flows


πŸ—οΈ Production Insights

From a real-world system serving high traffic:

  • Small validation bugs can scale into massive API waste
  • Race conditions are often invisible locally
  • Libraries are not always safe for side-effect-heavy flows

πŸ‘‰ Always design defensively


πŸ”‘ Key Takeaways

  • Validation must be pure
    Avoid side effects inside superRefine

  • Control execution manually
    Use safeParseAsync

  • Prevent race conditions
    Use useRef as a semaphore

  • Tune network behavior
    Disable refetchOnWindowFocus when needed


πŸ’¬ Final Thoughts

If your validation triggers APIs, you are no longer just validating
you are orchestrating stateful workflows.

πŸ‘‰ Treat it like backend logic, not just form validation.


πŸš€ Discussion

Have you experienced similar issues with async validation or race
conditions in React forms?

Let's discuss πŸ‘‡


Top comments (0)