Description
Describe the bug
When using a Zod schema with an asynchronous refine
or superRefine
validation directly within TanStack Form's onSubmitAsync
validator option, the asynchronous function (which performs a network request in this case) appears to be executed twice in first submission attempt. This results in duplicate network request being visible in the browser's Network tab. The subsequent submission attempts runs only once.
Your minimal, reproducible example
https://codesandbox.io/p/sandbox/serene-brook-gmt73s
Steps to reproduce
-
Define a Zod schema with async validation that performs a network request:
Using
refine
:import { z } from 'zod'; // Assume checkUser is an async function that performs a network request // It might fetch data or post to an endpoint, throwing on failure. // async function checkUser(username: string): Promise<void> { /* ... network request ... */ } export const asyncIgnoredUsersSchemaRefine = z.object({ ignoredUser: z.string().refine(async value => { console.log('Attempting async refine check for:', value); // For debugging context try { // This function triggers a network request await checkUser(value); return true; } catch { return false; } }, 'The user does not exist') });
Using
superRefine
:import { z } from 'zod'; // Assume checkUser performs a network request // async function checkUser(username: string): Promise<void> { /* ... network request ... */ } export const asyncIgnoredUsersSchemaSuperRefine = z.object({ ignoredUser: z.string().superRefine(async (arg, ctx) => { console.log('Attempting async superRefine check for:', arg); // For debugging context try { // This function triggers a network request await checkUser(arg); } catch (error) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: (error as Error).message }); } }) });
-
Use the schema in
useAppForm
(oruseForm
)onSubmitAsync
:import { useAppForm } from './path/to/useAppForm'; // Or useForm from @tanstack/react-form import { asyncIgnoredUsersSchemaRefine } from './path/to/schema'; // Assume getIgnoredUsersSchema(data) returns a synchronous Zod schema // Assume data is available // Assume mutatePartial is defined // ... inside your component const form = useAppForm({ defaultValues: { ignoredUser: '' }, validators: { onSubmit: getIgnoredUsersSchema(data), // Synchronous validation onSubmitAsync: asyncIgnoredUsersSchemaRefine // <--- Problematic usage // Using asyncIgnoredUsersSchemaSuperRefine here shows the same duplicate network request }, onSubmit: ({ value: { ignoredUser }, formApi }) => { mutatePartial(oldData => ({ ignoredUsers: [...oldData.ignoredUsers, ignoredUser.trim()] })); formApi.reset(); } }); // ... rest of the component (render form, etc.)
-
Trigger form submission: Open the browser's Network tab, fill the
ignoredUser
field, and submit the form.
Expected behavior
The asynchronous validation logic within refine
or superRefine
should execute only once. Consequently, the network request initiated by the checkUser
function should appear only once in the Network tab per submission attempt.
How often does this bug happen?
Every time
Screenshots or Videos
No response
Platform
- OS: Windows
- Browser: Brave
- Version: 1.77.97 - Chromium: 135.0.7049.84
TanStack Form adapter
None
TanStack Form version
1.6.3
TypeScript version
5.8.3
Additional context
Actual Behavior:
The network request initiated by the checkUser
function (triggered via the async refine
or superRefine
) appears twice in the browser's Network tab for a single form submission. The console logs (if added) might also appear twice, confirming the duplicate execution.
Workaround:
A workaround involves manually calling Zod's safeParseAsync
method within the onSubmitAsync
function body, instead of passing the schema directly. This prevents the double execution and thus the duplicate network request.
import { useAppForm } from './path/to/useAppForm';
import { asyncIgnoredUsersSchemaRefine } from './path/to/schema';
import { z, ZodIssue } from 'zod';
// Assume getIgnoredUsersSchema(data) returns a synchronous Zod schema
// Assume data is available
// Assume mutatePartial is defined
// Helper function (as provided in the original report)
function prefixSchemaToErrors(issues: readonly ZodIssue[]) {
const schema = new Map<string, ZodIssue[]>();
for (const issue of issues) {
const path = [...issue.path]
.map(segment => (typeof segment === 'number' ? `[${segment}]` : segment))
.join('.')
.replace(/\.\[/g, '[');
schema.set(path, (schema.get(path) ?? []).concat(issue));
}
return Object.fromEntries(schema);
}
// ... inside your component
const form = useAppForm({
defaultValues: {
ignoredUser: ''
},
validators: {
onSubmit: getIgnoredUsersSchema(data),
onSubmitAsync: async ({ value }) => { // <--- Workaround implementation
console.log('Manually triggering safeParseAsync');
// This validation triggers the network request via checkUser
const validation = await asyncIgnoredUsersSchemaRefine.safeParseAsync(value);
// Check network tab: the request from checkUser should appear only once now.
if (!validation.success) {
const schemaErrors = prefixSchemaToErrors(validation.error.issues);
// TanStack Form expects errors in a specific format
// Adjust structure based on TanStack Form version if needed
return {
form: schemaErrors , // General form error
field: schemaErrors // Field-specific errors
};
}
// Return undefined or void if validation passes
return undefined;
}
},
onSubmit: ({ value: { ignoredUser }, formApi }) => {
mutatePartial(oldData => ({ ignoredUsers: [...oldData.ignoredUsers, ignoredUser.trim()] }));
formApi.reset();
}
});
// ... rest of the component
(Note: The error formatting in the workaround might need adjustment based on the specific TanStack Form version).
Possible Cause:
This seems related to how TanStack Form internally handles or invokes Zod schemas provided to onSubmitAsync. It might be triggering the schema's asynchronous validation process more than once, leading to the unintended duplicate side effect (the network request).