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
}
});
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
-
superRefinecontains 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);
β 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);
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;
}
};
π 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,
},
},
});
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 insidesuperRefineControl execution manually
UsesafeParseAsyncPrevent race conditions
UseuseRefas a semaphoreTune network behavior
DisablerefetchOnWindowFocuswhen 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)