Fix: Eliminating Double Async Validation in TanStack Form & Zod
Source: Dev.to
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 a poor user experience.
🚨 The Problem: Double Async Execution
A known issue in TanStack Form (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
},
});
Why It’s Dangerous
In flows like OTP authentication:
- Multiple requests generate different codes
- The 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.
superRefinecontains side effects, turning validation into a non‑pure function.- Validation is no longer idempotent, breaking expectations.
✅ The Solution: Take Back Control
We fix the issue with three architectural decisions.
1. Manual Validation with safeParseAsync
Avoid relying on automatic validation during submission.
const result = await myZodSchema.safeParseAsync(form.state.values);
- Prevents double execution
- Gives full control over the validation lifecycle
2. Prevent Re‑entrancy with useRef
React state isn’t always fast enough to block rapid interactions. Use a low‑level semaphore:
const isSubmittingRef = useRef(false);
3. Decouple Side Effects
Never trigger API calls inside validation.
- Validation must remain pure
- Side effects go inside a controlled submit flow
🧩 Full Implementation
import { useRef } from 'react';
import { useForm } from '@tanstack/react-form';
import { myZodSchema } from './schema';
import { triggerOtpRequest } from './api';
const isSubmittingRef = useRef(false);
const handleSubmit = async () => {
if (isSubmittingRef.current) return; // prevent re‑entrancy
isSubmittingRef.current = true;
try {
// 1. Manual validation
const result = await myZodSchema.safeParseAsync(form.state.values);
if (!result.success) {
// map errors to the form if needed
return;
}
// 2. Execute side effect ONCE
await triggerOtpRequest(result.data);
} finally {
isSubmittingRef.current = false;
}
};
🌐 Network Layer Optimization
During testing another issue surfaced: unwanted refetching when the user switches tabs, which disrupts the OTP flow.
Fix: Adjust QueryClient configuration
import { QueryClient } from '@tanstack/react-query';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes
refetchOnWindowFocus: false, // disable automatic refetch
},
},
});
- Prevents UI state resets when the user returns to the tab
- Reduces unnecessary network traffic in critical flows
🏗️ Production Insights
- Small validation bugs can scale into massive API waste under high traffic.
- Race conditions are often invisible in local development.
- Libraries may not be safe for side‑effect‑heavy flows.
Takeaway: Design defensively and keep validation pure.
🔑 Key Takeaways
- Validation must be pure – avoid side effects inside
superRefine. - Control execution manually – use
safeParseAsyncfor explicit validation. - Prevent race conditions – employ
useRefas a semaphore. - Tune network behavior – disable
refetchOnWindowFocuswhen appropriate.
💬 Final Thoughts
If your validation triggers APIs, you’re no longer just validating—you’re orchestrating stateful workflows. Treat it like backend logic, not merely form validation.