form icon indicating copy to clipboard operation
form copied to clipboard

Zod errors from server action aren't propagated to field error.

Open AdamZajler opened this issue 5 months ago • 7 comments

Describe the bug

Hi :)

I'm trying to implement a tanstack form in my project, but I've encountered a problem. Namely, validation errors from the server-side action using the ZOD aren't being sent to the form's inputs. They're visible in state and the form itself, but they're not visible in field.state.meta.errors 🙆‍♀️

I followed the official guide and some YouTube tutorials, but there aren't enough examples online for this scenario :/

I'd like to avoid returning my own object from "zodErrors" and parsing it in the client component; I'd like to use the returned function from createServerValidate.

I validate all the code in onBlur and onSubmit using the ZOD scheme => and everything works. ✔️

However, when I do the same in the server action:

const serverValidate = createServerValidate({
onServerValidate: formSchema,
});

the errors don't get sent to the fields. They're visible in state and form, but they don't propagate downstream.

Normal behavior

Validation with ZOD, from onBlur / onChange:

Image Image

Behavior when errors are comming from server action

Image Image

Your minimal, reproducible example

https://codesandbox.io/p/github/AdamZajler/tanstack-form-nextjs-mui-server-action-zod/main // https://github.com/AdamZajler/tanstack-form-nextjs-mui-server-action-zod

Steps to reproduce

  1. Run project
  2. Enter some values, for ex. firstName: 123 and random valid e-mail address
  3. Press enter
  4. Now you will be seing a VALID response, all errors will be visible under inputs
  5. Now fill form with firstName with required length, ex. 123456
  6. Now errors will not be shown under inputs

Expected behavior

Errors from server-side field validation will appear under specific fields in the client component; just like with onBlur/onChange validation, because the error format is the same

How often does this bug happen?

Every time

Screenshots or Videos

No response

Platform

  • Ubuntu 24
  • Chrome

TanStack Form adapter

react-form

TanStack Form version

v1.19.2

TypeScript version

v5.9.2

Additional context

Has anyone encountered this problem? How did the list solve it? Can anyone provide a working example?

AdamZajler avatar Aug 18 '25 17:08 AdamZajler

Any update on this?

mikkelwf avatar Oct 01 '25 12:10 mikkelwf

!!! Also the createServerValidate is using the old getHeader completely breaking this. For now you can return your errorMap.onServer in onMount() => { fields: state.errorMap.onServer }

elmarvr avatar Oct 05 '25 13:10 elmarvr

🐛 Confirming the bug with a minimal React Router example

I ran into the same issue — server-side field errors (from createServerValidate) appear correctly in form.state.errorMap.onServer.fields, but they never propagate to individual field.state.meta.errors. This means UI conditions like:

const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;

never become true, and the inputs don’t show server errors, even though they exist in the form state.

Here’s the shape I get on the client after submitting:

{
  "errorMap": {
    "onServer": {
      "fields": {
        "username": "That username is already taken."
      }
    }
  },
  "values": {
    "username": "test"
  },
  "errors": []
}

So the data makes it back to the client, but it’s not merged into meta.errors for that specific field.


✅ Expected behavior

Server-side field errors returned from createServerValidate (via ServerValidateError) should be merged into each field.state.meta.errors, just like onBlur / onChange validation errors are.


💡 Minimal Reproduction (React Router)

Click to view code
import { z } from 'zod';
import {
  formOptions,
  initialFormState,
  createServerValidate,
  ServerValidateError,
} from '@tanstack/react-form/remix';
import { mergeForm, useForm, useTransform } from '@tanstack/react-form';
import { Form } from 'react-router';

import type { Route } from './+types/profile-form-example';

// Schema
const schema = z.object({
  username: z.string().min(3, 'Min 3 chars'),
});

// Client form config
const formConfig = formOptions({
  defaultValues: { username: '' },
  validators: { onSubmit: schema },
});

// Server validation that always fails username
const serverValidate = createServerValidate({
  ...formConfig,
  onServerValidate: ({ value }) => {
    if (value.username) {
      return { fields: { username: 'That username is already taken.' } };
    }
  },
});

// Action
export async function action({ request }: Route.ActionArgs) {
  try {
    const formData = await request.formData();
    const validated = await serverValidate(formData);
    return validated;
  } catch (err) {
    if (err instanceof ServerValidateError) {
      console.log(
        'server errorMap.onServer.fields:',
        err.formState.errorMap.onServer?.fields,
      );
      return err.formState;
    }
    throw err;
  }
}

// Component
export default function ProfileFormExample({ actionData }: Route.ComponentProps) {
  const form = useForm({
    ...formConfig,
    transform: useTransform(
      base => mergeForm(base, actionData ?? initialFormState),
      [actionData],
    ),
  });

  return (
    <main style={{ padding: 16 }}>
      <Form method="post" onSubmit={() => form.handleSubmit()}>
        <form.Field
          name="username"
          children={field => {
            const isInvalid =
              field.state.meta.isTouched && !field.state.meta.isValid;

            return (
              <div style={{ display: 'grid', gap: 8, maxWidth: 360 }}>
                <label htmlFor={field.name}>Username</label>
                <input
                  id={field.name}
                  name={field.name}
                  value={field.state.value}
                  onBlur={field.handleBlur}
                  onChange={e => field.handleChange(e.target.value)}
                  aria-invalid={isInvalid || undefined}
                />

                {isInvalid && field.state.meta.errors.length > 0 && (
                  <p style={{ color: 'crimson' }}>
                    {field.state.meta.errors.join(', ')}
                  </p>
                )}

                <button type="submit">Submit</button>

                <pre style={{ fontSize: 12, background: '#f6f6f6', padding: 8 }}>
{JSON.stringify(
  {
    'field.meta.isTouched': field.state.meta.isTouched,
    'field.meta.isValid': field.state.meta.isValid,
    'field.meta.errors': field.state.meta.errors, // stays empty
    'form.errorMap.onServer.fields':
      form.state.errorMap.onServer?.fields ?? null, // contains { username: '...' }
  },
  null,
  2,
)}
                </pre>
              </div>
            );
          }}
        />
      </Form>
    </main>
  );
}

🧩 Observed behavior

  • Client-side Zod validation (onSubmit) works correctly.
  • The ServerValidateError is caught and returned from the action.
  • The client receives errorMap.onServer.fields.username.
  • The field’s meta.errors remain empty, so UI doesn’t update.

🔍 Summary

It seems mergeForm (or the internal handling of onServer error maps) doesn’t populate meta.errors for each field when the new state comes from the server. Everything else merges correctly.

This makes server-side validation errors invisible at the field level, even though they’re present in the form state.

janhesters avatar Oct 12 '25 10:10 janhesters

Encountering this problem now but it seems even more generic than just Zod errors. Any errors that you send aren't linked to a field at all, even if you try your best to say that they are invalid.

GenericNerd avatar Oct 21 '25 17:10 GenericNerd

!!! Also the createServerValidate is using the old getHeader completely breaking this. For now you can return your errorMap.onServer in onMount() => { fields: state.errorMap.onServer }

For anyone landing on this issue, I encountered the same getHeader issue (@elmarvr) when using the latest @tanstack/react-start. #1771 should fix it!

boertel avatar Oct 22 '25 05:10 boertel

Also I think it would be nice to throw server error and n different circumstances. Not just the initial parse.

elmarvr avatar Oct 22 '25 05:10 elmarvr

solved by using this in remix/react-router7

  useEffect(() => {
    if (fetcher.data) {
      form.setErrorMap(fetcher.data.errorMap)
    }
  }, [fetcher.data])

you can probably replace fetcher with your actionData in Next.

the transform: useTransform(...mergeForm ...) function seems useless. The useTransform is not event exported from @tanstack/react-form, I had to import it from @tanstack/react-form-remix. Which docs and examples do not mention. Docs for useTransform are missing. This lib and its docs are a joke. Sorry.

petrkrizek avatar Dec 06 '25 03:12 petrkrizek