diff --git a/.changeset/loud-arrays-report.md b/.changeset/loud-arrays-report.md
new file mode 100644
index 0000000000..d915b90b26
--- /dev/null
+++ b/.changeset/loud-arrays-report.md
@@ -0,0 +1,6 @@
+---
+'@tanstack/react-form': patch
+'@tanstack/preact-form': patch
+---
+
+Rerender `ArrayField` when the array's own errors change, so an array-level validator error shows without a change to the array's length.
diff --git a/packages/preact-form/src/PreactForm/fieldSubscriptions.lib.ts b/packages/preact-form/src/PreactForm/fieldSubscriptions.lib.ts
index 614d909d93..1916647412 100644
--- a/packages/preact-form/src/PreactForm/fieldSubscriptions.lib.ts
+++ b/packages/preact-form/src/PreactForm/fieldSubscriptions.lib.ts
@@ -1,4 +1,4 @@
-import { useSelector } from '@tanstack/preact-store'
+import { shallow, useSelector } from '@tanstack/preact-store'
import { useMemo } from 'preact/compat'
import type { AnyInternalFieldApi } from '@tanstack/form-core/internals'
@@ -23,10 +23,14 @@ export function useArrayFieldSubscription(
fieldApi.atom,
(state) => state.meta._arrayVersion,
)
+ const errors = useSelector(fieldApi.atom, (state) => state.meta.errors, {
+ compare: shallow,
+ })
return useMemo(() => {
void length
void arrayVersion
+ void errors
return Object.create(fieldApi)
- }, [fieldApi, arrayVersion, length])
+ }, [fieldApi, arrayVersion, length, errors])
}
diff --git a/packages/preact-form/tests/FormField.spec.tsx b/packages/preact-form/tests/FormField.spec.tsx
index 528ac23254..0c79cb2f55 100644
--- a/packages/preact-form/tests/FormField.spec.tsx
+++ b/packages/preact-form/tests/FormField.spec.tsx
@@ -345,6 +345,85 @@ describe('Form fields', () => {
expect(renderCounts.last).toBe(initialCounts.last)
})
+ it('rerenders an ArrayField when its own errors change', async () => {
+ let renders = 0
+
+ function Component() {
+ const form = useForm({
+ defaultValues: { items: [{ label: 'A' }] },
+ errorVisibility: ({ state }) => state.submissionAttempts > 0,
+ })
+
+ return (
+ <>
+
+
+ value.length < 2 ? 'Add another item' : undefined,
+ },
+ ]}
+ >
+ {(field) => {
+ renders++
+ return (
+
+ )
+ }}
+
+
+ value === 'AB' ? 'Too long' : undefined,
+ },
+ ]}
+ >
+ {(field) => (
+ <>
+
+ field.handleChange(event.currentTarget.value)
+ }
+ />
+
+ {field.meta.original.errors
+ .map((error) => error.message)
+ .join(',')}
+
+ >
+ )}
+
+ >
+ )
+ }
+
+ const { getByLabelText, getByRole, getByTestId } = render()
+ const initialRenders = renders
+
+ await userEvent.type(getByLabelText('label'), 'B')
+
+ await expect
+ .element(getByTestId('label-error'))
+ .toHaveTextContent('Too long')
+ expect(renders).toBe(initialRenders)
+
+ await getByRole('button', { name: 'Submit' }).click()
+
+ await expect
+ .element(getByTestId('array'))
+ .toHaveTextContent('Add another item')
+ })
+
it('should recreate mounted fields after form reset', async () => {
function Component() {
const form = useForm({ defaultValues: { name: 'tony-hawk' } })
diff --git a/packages/react-form/src/ReactForm/fieldSubscriptions.lib.ts b/packages/react-form/src/ReactForm/fieldSubscriptions.lib.ts
index d67491fedf..c14b3a40ba 100644
--- a/packages/react-form/src/ReactForm/fieldSubscriptions.lib.ts
+++ b/packages/react-form/src/ReactForm/fieldSubscriptions.lib.ts
@@ -1,4 +1,4 @@
-import { useSelector } from '@tanstack/react-store'
+import { shallow, useSelector } from '@tanstack/react-store'
import { useMemo } from 'react'
import type { AnyInternalFieldApi } from '@tanstack/form-core/internals'
@@ -21,10 +21,14 @@ export function useArrayFieldSubscription(
fieldApi.atom,
(state) => state.meta._arrayVersion,
)
+ const errors = useSelector(fieldApi.atom, (state) => state.meta.errors, {
+ compare: shallow,
+ })
return useMemo(() => {
void length
void arrayVersion
+ void errors
return Object.create(fieldApi)
- }, [fieldApi, arrayVersion, length])
+ }, [fieldApi, arrayVersion, length, errors])
}
diff --git a/packages/react-form/tests/FormField.spec.tsx b/packages/react-form/tests/FormField.spec.tsx
index 766f022de9..ee191450e8 100644
--- a/packages/react-form/tests/FormField.spec.tsx
+++ b/packages/react-form/tests/FormField.spec.tsx
@@ -141,6 +141,83 @@ describe('Form fields', () => {
expect(renders).toBe(replacementRenders)
})
+ it('rerenders an ArrayField when its own errors change', async () => {
+ let renders = 0
+
+ function Component() {
+ const form = useForm({
+ defaultValues: { items: [{ label: 'A' }] },
+ errorVisibility: ({ state }) => state.submissionAttempts > 0,
+ })
+
+ return (
+ <>
+
+
+ value.length < 2 ? 'Add another item' : undefined,
+ },
+ ]}
+ >
+ {(field) => {
+ renders++
+ return (
+
+ )
+ }}
+
+
+ value === 'AB' ? 'Too long' : undefined,
+ },
+ ]}
+ >
+ {(field) => (
+ <>
+ field.handleChange(event.target.value)}
+ />
+
+ {field.meta.original.errors
+ .map((error) => error.message)
+ .join(',')}
+
+ >
+ )}
+
+ >
+ )
+ }
+
+ const screen = await render()
+ const initialRenders = renders
+
+ await userEvent.type(screen.getByLabelText('label'), 'B')
+
+ await vi.waitFor(() =>
+ expect(screen.getByTestId('label-error')).toHaveTextContent('Too long'),
+ )
+ expect(renders).toBe(initialRenders)
+
+ await screen.getByRole('button', { name: 'Submit' }).click()
+
+ await vi.waitFor(() =>
+ expect(screen.getByTestId('array')).toHaveTextContent('Add another item'),
+ )
+ })
+
it('should have the correct meta when changing the field', async () => {
function Component() {
const form = useForm({ defaultValues: { name: 'tony-hawk' } })