Repository: TanStack/form (TypeScript)

Issue:
"Cannot read properties of undefined" when removing array field
### Describe the bug

When removing elements from array using the removeFieldValue api, we sometimes receive:
```
Uncaught (in promise) TypeError: Cannot read properties of undefined (reading 'errorMap')
```

I am able to reproduce this when removing an element that contains a field inside that has an error. 
At some point in the validation logic triggered within removeFieldValue, we're reaching out to get the errorMap for a field that's undefined at that point. 

### Your minimal, reproducible example

https://stackblitz.com/edit/vitejs-vite-ypgebmzr?file=src%2Fcomponents%2FForm.tsx

### Steps to reproduce

Simply add a few rows, then delete at least 2. Take a look at the console and you will see:
```
@tanstack_react-form.js?v=b58a52f4:970 Uncaught (in promise) TypeError: Cannot read properties of undefined (reading 'errorMap')
    at @tanstack_react-form.js?v=b58a52f4:970:27
    at functionalUpdate (@tanstack_react-form.js?v=b58a52f4:410:42)
    at @tanstack_react-form.js?v=b58a52f4:1132:22
    at Store.setState (@tanstack_react-form.js?v=b58a52f4:283:124)
    at FormApi.setFieldMeta (@tanstack_react-form.js?v=b58a52f4:1127:22)
    at @tanstack_react-form.js?v=b58a52f4:967:20
    at batch (@tanstack_react-form.js?v=b58a52f4:255:5)
    at FormApi.validateSync (@tanstack_react-form.js?v=b58a52f4:924:7)
    at FieldApi.validate (@tanstack_react-form.js?v=b58a52f4:1894:123)
    at FormApi.validateField (@tanstack_react-form.js?v=b58a52f4:918:28)
```

Notice how when we push rows we are pushing a value that is not valid (per the form level schema) to the interestLevel field. If we were to push a valid value (greater than 5) the issue goes away.

### Expected behavior

When I remove an array element, regardless of subfields containing errors or not, we should never be attempting to pull errorMap from undefined (resulting in the error message).

### How often does this bug happen?

Every time

### Screenshots or Videos

_No response_

### Platform

React, TypeScript, 

### TanStack Form adapter

None

### TanStack Form version

v1.1.2

### TypeScript version

v5.7.2

### Additional context

This will affect any users that attempt to use a standard schema validator at form level and attempt to delete a row with subfields containing errors. I'm looking into fixing it already, please assign me the issue if you have access! Thanks!


Discussion: Determined that this issue actually plagues all validations post array shifting/inserting/moving/removing....

I have opened #1324 to resolve this in a much cleaner way.
I'm having the same issue.
Hope you guys like this approach, it'll allow us to fix the issue without a breaking change.

Long term we may want to update this type though to include the source:

```ts
export type ValidationErrorMap<
  TOnMountReturn = unknown,
  TOnChangeReturn = unknown,
  TOnChangeAsyncReturn = unknown,
  TOnBlurReturn = unknown,
  TOnBlurAsyncReturn = unknown,
  TOnSubmitReturn = unknown,
  TOnSubmitAsyncReturn = unknown,
  TOnServerReturn = unknown,
> = {
  onMount?: {
    error: TOnMountReturn
    source: ValidationSource
  }
  onChange?: {
    error: TOnChangeReturn | TOnChangeAsyncReturn
    source: ValidationSource
  }
  onBlur?: {
    error: TOnBlurReturn | TOnBlurAsyncReturn
    source: ValidationSource
  }
  onSubmit?: {
    error: TOnSubmitReturn | TOnSubmitAsyncReturn
    source: ValidationSource
  }
  onServer?: {
    error: TOnServerReturn
    source: ValidationSource
  }
}
```

This way we minimize the use of memory and have a single source of truth. Once we get closer to V2 I'm happy to implement that if you like it. For now, we the current implementation of a separate errorSourceMap will work without breaking existing usages.



Fix (patch):
diff --git a/packages/form-core/src/FieldApi.ts b/packages/form-core/src/FieldApi.ts
index a95fd5515..18940ac2a 100644
--- a/packages/form-core/src/FieldApi.ts
+++ b/packages/form-core/src/FieldApi.ts
@@ -4,7 +4,12 @@ import {
   standardSchemaValidators,
 } from './standardSchemaValidator'
 import { defaultFieldMeta } from './metaHelper'
-import { getAsyncValidatorArray, getBy, getSyncValidatorArray } from './utils'
+import {
+  determineFieldLevelErrorSourceAndValue,
+  getAsyncValidatorArray,
+  getBy,
+  getSyncValidatorArray,
+} from './utils'
 import type { DeepKeys, DeepValue, UnwrapOneLevelOfArray } from './util-types'
 import type {
   StandardSchemaV1,
@@ -25,6 +30,7 @@ import type {
   ValidationCause,
   ValidationError,
   ValidationErrorMap,
+  ValidationErrorMapSource,
 } from './types'
 import type { AsyncValidator, SyncValidator, Updater } from './utils'
 
@@ -561,6 +567,10 @@ export type FieldMetaBase<
     UnwrapFieldValidateOrFn<TName, TOnSubmit, TFormOnSubmit>,
     UnwrapFieldAsyncValidateOrFn<TName, TOnSubmitAsync, TFormOnSubmitAsync>
   >
+  /**
+   * @private allows tracking the source of the errors in the error map
+   */
+  errorSourceMap: ValidationErrorMapSource
   /**
    * A flag indicating whether the field is currently being validated.
    */
@@ -1101,6 +1111,11 @@ export class FieldApi<
               ...prev,
               // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
               errorMap: { ...prev?.errorMap, onMount: error },
+              errorSourceMap: {
+                // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+                ...prev?.errorSourceMap,
+                onMount: 'field',
+              },
             }) as never,
         )
       }
@@ -1345,39 +1360,43 @@ export class FieldApi<
       ) => {
         const errorMapKey = getErrorMapKey(validateObj.cause)
 
-        const error =
-          /*
-            If `validateObj.validate` is `undefined`, then the field doesn't have
-            a validator for this event, but there still could be an error that
-            needs to be cleaned up related to the current event left by the
-            form's validator.
-          */
-          validateObj.validate
-            ? normalizeError(
-                field.runValidator({
-                  validate: validateObj.validate,
-                  value: {
-                    value: field.store.state.value,
-                    validationSource: 'field',
-                    fieldApi: field,
-                  },
-                  type: 'validate',
-                }),
-              )
-            : errorFromForm[errorMapKey]
+        const fieldLevelError = validateObj.validate
+          ? normalizeError(
+              field.runValidator({
+                validate: validateObj.validate,
+                value: {
+                  value: field.store.state.value,
+                  validationSource: 'field',
+                  fieldApi: field,
+                },
+                type: 'validate',
+              }),
+            )
+          : undefined
 
-        if (field.state.meta.errorMap[errorMapKey] !== error) {
+        const formLevelError = errorFromForm[errorMapKey]
+
+        const { newErrorValue, newSource } =
+          determineFieldLevelErrorSourceAndValue({
+            formLevelError,
+            fieldLevelError,
+          })
+
+        // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+        if (field.state.meta.errorMap?.[errorMapKey] !== newErrorValue) {
           field.setMeta((prev) => ({
             ...prev,
             errorMap: {
               ...prev.errorMap,
-              [getErrorMapKey(validateObj.cause)]:
-                // Prefer the error message from the field validators if they exist
-                error ? error : errorFromForm[errorMapKey],
+              [errorMapKey]: newErrorValue,
+            },
+            errorSourceMap: {
+              ...prev.errorSourceMap,
+              [errorMapKey]: newSource,
             },
           }))
         }
-        if (error || errorFromForm[errorMapKey]) {
+        if (newErrorValue) {
           hasErrored = true
         }
       }
@@ -1398,7 +1417,8 @@ export class FieldApi<
     const submitErrKey = getErrorMapKey('submit')
 
     if (
-      this.state.meta.errorMap[submitErrKey] &&
+      // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+      this.state.meta.errorMap?.[submitErrKey] &&
       cause !== 'submit' &&
       !hasErrored
     ) {
@@ -1408,6 +1428,10 @@ export class FieldApi<
           ...prev.errorMap,
           [submitErrKey]: undefined,
         },
+        errorSourceMap: {
+          ...prev.errorSourceMap,
+          [submitErrKey]: undefined,
+        },
       }))
     }
 
@@ -1521,22 +1545,33 @@ export class FieldApi<
             rawError = e as ValidationError
           }
           if (controller.signal.aborted) return resolve(undefined)
-          const error = normalizeError(rawError)
-          const fieldErrorFromForm =
+
+          const fieldLevelError = normalizeError(rawError)
+          const formLevelError =
             asyncFormValidationResults[this.name]?.[errorMapKey]
-          const fieldError = error || fieldErrorFromForm
+
+          const { newErrorValue, newSource } =
+            determineFieldLevelErrorSourceAndValue({
+              formLevelError,
+              fieldLevelError,
+            })
+
           field.setMeta((prev) => {
             return {
               ...prev,
               errorMap: {
                 // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
                 ...prev?.errorMap,
-                [errorMapKey]: fieldError,
+                [errorMapKey]: newErrorValue,
+              },
+              errorSourceMap: {
+                ...prev.errorSourceMap,
+                [errorMapKey]: newSource,
               },
             }
           })
 
-          resolve(fieldError)
+          resolve(newErrorValue)
         }),
       )
     }
diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts
index 6d8bf2004..eaa5544fe 100644
--- a/packages/form-core/src/FormApi.ts
+++ b/packages/form-core/src/FormApi.ts
@@ -1,6 +1,7 @@
 import { Derived, Store, batch } from '@tanstack/store'
 import {
   deleteBy,
+  determineFormLevelErrorSourceAndValue,
   functionalUpdate,
   getAsyncValidatorArray,
   getBy,
@@ -733,22 +734,6 @@ export class FormApi<
    */
   prevTransformArray: unknown[] = []
 
-  /**
-   * @private Persistent store of all field validation errors originating from form-level validators.
-   * Maintains the cumulative state across validation cycles, including cleared errors (undefined values).
-   * This map preserves the complete validation state for all fields.
-   */
-  cumulativeFieldsErrorMap: FormErrorMapFromValidator<
-    TFormData,
-    TOnMount,
-    TOnChange,
-    TOnChangeAsync,
-    TOnBlur,
-    TOnBlurAsync,
-    TOnSubmit,
-    TOnSubmitAsync
-  > = {}
-
   /**
    * Constructs a new `FormApi` instance with the given form options.
    */
@@ -1306,56 +1291,56 @@ export class FormApi<
 
         const errorMapKey = getErrorMapKey(validateObj.cause)
 
-        if (fieldErrors) {
-          for (const [field, fieldError] of Object.entries(fieldErrors) as [
-            DeepKeys<TFormData>,
-            ValidationError,
-          ][]) {
-            const oldErrorMap = this.cumulativeFieldsErrorMap[field] || {}
-            const newErrorMap = {
-              ...oldErrorMap,
-              [errorMapKey]: fieldError,
-            }
-            currentValidationErrorMap[field] = newErrorMap
-            this.cumulativeFieldsErrorMap[field] = newErrorMap
+        for (const field of Object.keys(
+          this.state.fieldMeta,
+        ) as DeepKeys<TFormData>[]) {
+          const fieldMeta = this.getFieldMeta(field)
+          if (!fieldMeta) continue
+
+          const {
+            errorMap: currentErrorMap,
+            errorSourceMap: currentErrorMapSource,
+          } = fieldMeta
+
+          const newFormValidatorError = fieldErrors?.[field]
+
+          const { newErrorValue, newSource } =
+            determineFormLevelErrorSourceAndValue({
+              newFormValidatorError,
+              isPreviousErrorFromFormValidator:
+                // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+                currentErrorMapSource?.[errorMapKey] === 'form',
+              // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+              previousErrorValue: currentErrorMap?.[errorMapKey],
+            })
 
-            const fieldMeta = this.getFieldMeta(field)
-            if (fieldMeta && fieldMeta.errorMap[errorMapKey] !== fieldError) {
-              this.setFieldMeta(field, (prev) => ({
-                ...prev,
-                errorMap: {
-                  ...prev.errorMap,
-                  [errorMapKey]: fieldError,
-                },
-              }))
+          if (newSource === 'form') {
+            currentValidationErrorMap[field] = {
+              ...currentValidationErrorMap[field],
+              [errorMapKey]: newFormValidatorError,
             }
           }
-        }
 
-        for (const field of Object.keys(this.cumulativeFieldsErrorMap) as Array<
-          DeepKeys<TFormData>
-        >) {
-          const fieldMeta = this.getFieldMeta(field)
           if (
-            fieldMeta?.errorMap[errorMapKey] &&
-            !currentValidationErrorMap[field]?.[errorMapKey]
+            // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+            currentErrorMap?.[errorMapKey] !== newErrorValue
           ) {
-            this.cumulativeFieldsErrorMap[field] = {
-              ...this.cumulativeFieldsErrorMap[field],
-              [errorMapKey]: undefined,
-            }
-
             this.setFieldMeta(field, (prev) => ({
               ...prev,
               errorMap: {
                 ...prev.errorMap,
-                [errorMapKey]: undefined,
+                [errorMapKey]: newErrorValue,
+              },
+              errorSourceMap: {
+                ...prev.errorSourceMap,
+                [errorMapKey]: newSource,
               },
             }))
           }
         }
 
-        if (this.state.errorMap[errorMapKey] !== formError) {
+        // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+        if (this.state.errorMap?.[errorMapKey] !== formError) {
           this.baseStore.setState((prev) => ({
             ...prev,
             errorMap: {
@@ -1376,7 +1361,8 @@ export class FormApi<
        */
       const submitErrKey = getErrorMapKey('submit')
       if (
-        this.state.errorMap[submitErrKey] &&
+        // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+        this.state.errorMap?.[submitErrKey] &&
         cause !== 'submit' &&
         !hasErrored
       ) {
@@ -1422,7 +1408,7 @@ export class FormApi<
      */
     const promises: Promise<ValidationPromiseResult<TFormData>>[] = []
 
-    let fieldErrors:
+    let fieldErrorsFromFormValidators:
       | Partial<Record<DeepKeys<TFormData>, ValidationError>>
       | undefined
 
@@ -1473,26 +1459,56 @@ export class FormApi<
             normalizeError<TFormData>(rawError)
 
           if (fieldErrorsFromNormalizeError) {
-            fieldErrors = fieldErrors
-              ? { ...fieldErrors, ...fieldErrorsFromNormalizeError }
+            fieldErrorsFromFormValidators = fieldErrorsFromFormValidators
+              ? {
+                  ...fieldErrorsFromFormValidators,
+                  ...fieldErrorsFromNormalizeError,
+                }
               : fieldErrorsFromNormalizeError
           }
           const errorMapKey = getErrorMapKey(validateObj.cause)
 
-          if (fieldErrors) {
-            for (const [field, fieldError] of Object.entries(fieldErrors)) {
-              const fieldMeta = this.getFieldMeta(field as DeepKeys<TFormData>)
-              if (fieldMeta && fieldMeta.errorMap[errorMapKey] !== fieldError) {
-                this.setFieldMeta(field as DeepKeys<TFormData>, (prev) => ({
-                  ...prev,
-                  errorMap: {
-                    ...prev.errorMap,
-                    [errorMapKey]: fieldError,
-                  },
-                }))
-              }
+          for (const field of Object.keys(
+            this.state.fieldMeta,
+          ) as DeepKeys<TFormData>[]) {
+            const fieldMeta = this.getFieldMeta(field)
+            if (!fieldMeta) continue
+
+            const {
+              errorMap: currentErrorMap,
+              errorSourceMap: currentErrorMapSource,
+            } = fieldMeta
+
+            const newFormValidatorError = fieldErrorsFromFormValidators?.[field]
+
+            const { newErrorValue, newSource } =
+              determineFormLevelErrorSourceAndValue({
+                newFormValidatorError,
+                isPreviousErrorFromFormValidator:
+                  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+                  currentErrorMapSource?.[errorMapKey] === 'form',
+                // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+                previousErrorValue: currentErrorMap?.[errorMapKey],
+              })
+
+            if (
+              // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+              currentErrorMap?.[errorMapKey] !== newErrorValue
+            ) {
+              this.setFieldMeta(field, (prev) => ({
+                ...prev,
+                errorMap: {
+                  ...prev.errorMap,
+                  [errorMapKey]: newErrorValue,
+                },
+                errorSourceMap: {
+                  ...prev.errorSourceMap,
+                  [errorMapKey]: newSource,
+                },
+              }))
             }
           }
+
           this.baseStore.setState((prev) => ({
             ...prev,
             errorMap: {
@@ -1501,7 +1517,11 @@ export class FormApi<
             },
           }))
 
-          resolve(fieldErrors ? { fieldErrors, errorMapKey } : undefined)
+          resolve(
+            fieldErrorsFromFormValidators
+              ? { fieldErrors: fieldErrorsFromFormValidators, errorMapKey }
+              : undefined,
+          )
         }),
       )
     }
diff --git a/packages/form-core/src/metaHelper.ts b/packages/form-core/src/metaHelper.ts
index eaa89af09..8e3e658df 100644
--- a/packages/form-core/src/metaHelper.ts
+++ b/packages/form-core/src/metaHelper.ts
@@ -16,6 +16,7 @@ export const defaultFieldMeta: AnyFieldMeta = {
   isPristine: true,
   errors: [],
   errorMap: {},
+  errorSourceMap: {},
 }
 
 export function metaHelper<
diff --git a/packages/form-core/src/types.ts b/packages/form-core/src/types.ts
index 7875a0f92..65baf0b9a 100644
--- a/packages/form-core/src/types.ts
+++ b/packages/form-core/src/types.ts
@@ -40,6 +40,17 @@ export type ValidationErrorMap<
   onServer?: TOnServerReturn
 }
 
+/**
+ * @private allows tracking the source of the errors in the error map
+ */
+export type ValidationErrorMapSource = {
+  onMount?: ValidationSource
+  onChange?: ValidationSource
+  onBlur?: ValidationSource
+  onSubmit?: ValidationSource
+  onServer?: ValidationSource
+}
+
 /**
  * @private
  */
diff --git a/packages/form-core/src/utils.ts b/packages/form-core/src/utils.ts
index ba15da7d5..92b9023e1 100644
--- a/packages/form-core/src/utils.ts
+++ b/packages/form-core/src/utils.ts
@@ -1,4 +1,9 @@
-import type { GlobalFormValidationError, ValidationCause } from './types'
+import type {
+  GlobalFormValidationError,
+  ValidationCause,
+  ValidationError,
+  ValidationSource,
+} from './types'
 import type { FormValidators } from './FormApi'
 import type { AnyFieldMeta, FieldValidators } from './FieldApi'
 
@@ -376,3 +381,65 @@ export function shallow<T>(objA: T, objB: T) {
   }
   return true
 }
+
+/**
+ * Determines the logic for determining the error source and value to set on the field meta within the form level sync/async validation.
+ * @private
+ */
+export const determineFormLevelErrorSourceAndValue = ({
+  newFormValidatorError,
+  isPreviousErrorFromFormValidator,
+  previousErrorValue,
+}: {
+  newFormValidatorError: ValidationError
+  isPreviousErrorFromFormValidator: boolean
+  previousErrorValue: ValidationError
+}): {
+  newErrorValue: ValidationError
+  newSource: ValidationSource | undefined
+} => {
+  // All falsy values are not considered errors
+  if (newFormValidatorError) {
+    return { newErrorValue: newFormValidatorError, newSource: 'form' }
+  }
+
+  // Clears form level error since it's now stale
+  if (isPreviousErrorFromFormValidator) {
+    return { newErrorValue: undefined, newSource: undefined }
+  }
+
+  // At this point, we have a preivous error which must have been set by the field validator, keep as is
+  if (previousErrorValue) {
+    return { newErrorValue: previousErrorValue, newSource: 'field' }
+  }
+
+  // No new or previous error, clear the error
+  return { newErrorValue: undefined, newSource: undefined }
+}
+
+/**
+ * Determines the logic for determining the error source and value to set on the field meta within the field level sync/async validation.
+ * @private
+ */
+export const determineFieldLevelErrorSourceAndValue = ({
+  formLevelError,
+  fieldLevelError,
+}: {
+  formLevelError: ValidationError
+  fieldLevelError: ValidationError
+}): {
+  newErrorValue: ValidationError
+  newSource: ValidationSource | undefined
+} => {
+  // At field level, we prioritize the field level error
+  if (fieldLevelError) {
+    return { newErrorValue: fieldLevelError, newSource: 'field' }
+  }
+
+  // If there is no field level error, and there is a form level error, we set the form level error
+  if (formLevelError) {
+    return { newErrorValue: formLevelError, newSource: 'form' }
+  }
+
+  return { newErrorValue: undefined, newSource: undefined }
+}