Tools: Fix: Eliminating Double Async Validation in TanStack Form & Zod

Tools: Fix: Eliminating Double Async Validation in TanStack Form & Zod

Source: Dev.to

🚨 The Problem: Double Async Execution ## Typical Setup ## Why It's Dangerous ## 🧠 Root Cause ## βœ… The Solution: Take Back Control ## 1. Manual Validation with safeParseAsync ## 2. Prevent Re-entrancy with useRef ## 3. Decouple Side Effects ## 🧩 Full Implementation ## 🌐 Network Layer Optimization ## Fix your QueryClient config: ## Why it matters ## πŸ—οΈ Production Insights ## πŸ”‘ Key Takeaways ## πŸ’¬ Final Thoughts ## πŸš€ Discussion 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 A known issue in TanStack Issue #1431 causes async validation (superRefine) to execute multiple times during submission. 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 πŸ‘‰ This breaks the expectation that validation is idempotent We fix the issue with 3 architectural decisions: Avoid relying on automatic validation during submission. βœ” Prevents double execution βœ” Gives full control over validation lifecycle React state is not always fast enough to block rapid interactions. Use a low-level semaphore: Never trigger API calls inside validation. πŸ‘‰ Validation must remain pure πŸ‘‰ Side effects go inside controlled submit flow During testing, another issue emerged: πŸ‘‰ unwanted re-fetching disrupting UX πŸ‘‰ Disable it for critical flows From a real-world system serving high traffic: πŸ‘‰ Always design defensively 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 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. Have you experienced similar issues with async validation or race conditions in React forms? Templates let you quickly answer FAQs or store snippets for re-use. Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment's permalink. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse COMMAND_BLOCK: 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 COMMAND_BLOCK: const form = useForm({ defaultValues: { ... }, validators: { onChange: myZodSchema, // async superRefine }, onSubmit: async ({ value }) => { await sendOtp(value); // ❌ may be called twice } }); COMMAND_BLOCK: const form = useForm({ defaultValues: { ... }, validators: { onChange: myZodSchema, // async superRefine }, onSubmit: async ({ value }) => { await sendOtp(value); // ❌ may be called twice } }); CODE_BLOCK: const result = await myZodSchema.safeParseAsync(form.state.values); Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: const result = await myZodSchema.safeParseAsync(form.state.values); CODE_BLOCK: const result = await myZodSchema.safeParseAsync(form.state.values); CODE_BLOCK: const isSubmittingRef = useRef(false); Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: const isSubmittingRef = useRef(false); CODE_BLOCK: const isSubmittingRef = useRef(false); COMMAND_BLOCK: 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 COMMAND_BLOCK: 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; } }; COMMAND_BLOCK: 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; } }; CODE_BLOCK: const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 5 * 60 * 1000, refetchOnWindowFocus: false, }, }, }); Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 5 * 60 * 1000, refetchOnWindowFocus: false, }, }, }); CODE_BLOCK: const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 5 * 60 * 1000, refetchOnWindowFocus: false, }, }, }); - TanStack Form may trigger validation multiple times internally - superRefine contains side effects - Validation β‰  Pure function anymore - Users switch tabs to check OTP - Returning triggers refetch - UI state resets unexpectedly - 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 - 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