TanStack / TanStack/form

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

Open
#1,431 14 comments 2 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

cause: external
Dominant language
TypeScript
Stars
6.7k
Forks
682
Avg merge
5d 18h
Merged PRs (30d)
7

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

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with the linked CodeSandbox and the onSubmitAsync handling for a Zod schema passed directly as a validator. Compare that path with the documented safeParseAsync workaround and trace the first submission until the async refine or superRefine runs twice. Done means one network request per submission while validation errors still reach the form.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
frontend
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Stale
Clarity
Mostly clear
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.