Skip to content

Zod's async refine / superRefine called twice when used with TanStack Form's onSubmitAsync validator #1431

Open
@Aleksnako

Description

@Aleksnako

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

  1. 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
          });
        }
      })
    });
  2. Use the schema in useAppForm (or useForm) 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.)
  3. 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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions