Skip to content

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

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
Aleksnako opened this issue Apr 20, 2025 · 4 comments

Comments

@Aleksnako
Copy link

Aleksnako commented Apr 20, 2025

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).

@LeCarbonator
Copy link
Contributor

LeCarbonator commented Apr 20, 2025

A note about the workaround:

You can use the parseValuesWithSchemaAsync method to get formatted errors more easily. See https://tanstack.com/form/latest/docs/reference/classes/fieldapi#parsevaluewithschemaasync for reference

onSubmitAsync: async ({ value, formApi }) => { // <--- Workaround implementation
      console.log('Manually triggering safeParseAsync');
      // This validation triggers the network request via checkUser
      return await formApi.parseValuesWithSchemaAsync(asyncIgnoredUsersSchemaRefine);
    }

Edit

This appears to also be an issue of onBlurAsync and onChangeAsync.
Test case below:

  it('should only run onSubmitAsync schemas once on submit', async () => {
    vi.useFakeTimers()
    const asyncCallMock = vi.fn()

    const schema = z.object({
      name: z.string().refine(async () => {
        asyncCallMock()
        return true
      }, 'Some error message'),
    })

    const form = new FormApi({
      defaultValues: {
        name: '',
      },
      validators: {
        onSubmitAsync: schema,
      },
    })
    form.mount()

    form.handleSubmit()

    await vi.runAllTimersAsync()
    expect(asyncCallMock).toHaveBeenCalledOnce()
  })

@Aleksnako
Copy link
Author

Aleksnako commented Apr 20, 2025

A note about the workaround:

You can use the parseValuesWithSchemaAsync method to get formatted errors more easily. See https://tanstack.com/form/latest/docs/reference/classes/fieldapi#parsevaluewithschemaasync for reference

onSubmitAsync: async ({ value, formApi }) => { // <--- Workaround implementation
console.log('Manually triggering safeParseAsync');
// This validation triggers the network request via checkUser
return await formApi.parseValuesWithSchemaAsync(asyncIgnoredUsersSchemaRefine);
}
Edit

This appears to also be an issue of onBlurAsync and onChangeAsync. Test case below:

it('should only run onSubmitAsync schemas once on submit', async () => {
vi.useFakeTimers()
const asyncCallMock = vi.fn()

const schema = z.object({
  name: z.string().refine(async () => {
    asyncCallMock()
    return true
  }, 'Some error message'),
})

const form = new FormApi({
  defaultValues: {
    name: '',
  },
  validators: {
    onSubmitAsync: schema,
  },
})
form.mount()

form.handleSubmit()

await vi.runAllTimersAsync()
expect(asyncCallMock).toHaveBeenCalledOnce()

})

I have also tried with that function, but it seems that it also happens. Maybe the problem is related?

I did my own helper function:

const 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)
}

export const checkSchemaOnSubmitAsync =
  <TFormData>(schema: ZodSchema<TFormData>): FormValidateAsyncFn<TFormData> =>
  async ({ value }) => {
    const validation = await schema.safeParseAsync(value)
    if (!validation.success) {
      const schemaErrors = prefixSchemaToErrors(validation.error.issues)
      return {
        form: schemaErrors,
        fields: schemaErrors
      }
    }
  }

@LeCarbonator
Copy link
Contributor

Interesting ... that should narrow it down quite a bit.

@Aleksnako
Copy link
Author


Complete Workaround Implementation

For anyone experiencing the double execution of async refine/superRefine when used directly in TanStack Form's async validators (like onSubmitAsync), here is a workaround that manually invokes Zod's safeParseAsync. This avoids passing the schema directly to TanStack Form and seems to prevent the duplicate execution.

1. Helper Functions & Factories:

import { z, ZodIssue, ZodSchema } from 'zod';

/**
 * Formats Zod issues into an object mapping dot-notation paths to issue arrays.
 * Useful for structuring errors for TanStack Form (may need adjustment based on version).
 */
const 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);
};

/**
 * Core async validator logic. Parses the value against the schema.
 * Returns Zod issues for field validation failure or a specific
 * error object structure for form validation failure.
 */
const checkSchemaOnValidatorAsync = async <TData>({
  schema,
  value,
  validationSource = 'field'
}: {
  schema: ZodSchema<TData>;
  value: TData;
  validationSource?: 'field' | 'form';
}) => {
  const validation = await schema.safeParseAsync(value);
  if (!validation.success) {
    // For field-level, return raw issues
    if (validationSource === 'field') {
        return validation.error.issues;
    }

    // For form-level, return the structured errors object
    const schemaErrors = prefixSchemaToErrors(validation.error.issues);
    return {
      form: schemaErrors,
      field: schemaErrors
    };
  }
  // Return undefined on success
  return undefined;
};

/**
 * Factory function to create an async validator suitable for TanStack Form FIELDS
 * (e.g., field-level onChangeAsync, onBlurAsync, onSubmitAsync).
 */
export const checkSchemaOnFieldValidatorAsync =
  <TData>(schema: ZodSchema<TData>) =>
  // Note: TanStack field validators often receive { value, signal, fieldApi }.
  // This simplified version only uses value. Adapt if signal/fieldApi are needed.
  ({ value }: { value: TData }) =>
    checkSchemaOnValidatorAsync({ schema, value, validationSource: 'field' });

/**
 * Factory function to create an async validator suitable for TanStack Form's
 * form-level async validators.
 */
export const checkSchemaOnFormValidatorAsync =
  <TFormData>(schema: ZodSchema<TFormData>): FormValidateAsyncFn<TFormData> =>
  ({ value }) => // Assumes FormValidateAsyncFn provides { value }
    checkSchemaOnValidatorAsync({ schema, value, validationSource: 'form' });

2. Usage Examples:
Here’s how you can use these factories:
A. Field-Level Async Validation (e.g., onSubmitAsync on a field)
Instead of passing the Zod schema directly to the field's validator prop, pass the result of the factory function.

<form.AppField
  name='username' // The name of the field in your form data
  validators={{
    // Use the factory for async validation on the field
    onSubmitAsync: checkSchemaOnFieldValidatorAsync(checkUserSchema)
  }}
  children={field => (
    // Your field rendering logic...
  )}
/>

B. Form-Level onSubmitAsync Validation
This directly addresses the original issue scenario. Use the checkSchemaOnFormValidatorAsync factory for the form's onSubmitAsync validator.

const form = useAppForm({
  defaultValues: {
    username: '',
    note: ''
  },
  validators: {
    // Use the factory for the form-level onSubmitAsync
    onSubmitAsync: checkSchemaOnFormValidatorAsync(annotatedUsersSchema) // <<--- Usage
  },
  onSubmit: ({ value, formApi }) => {
    // This runs only if sync and async validations pass
   ...
});

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants