diff --git a/docs/framework/react/guides/linked-fields.md b/docs/framework/react/guides/linked-fields.md
index c6065afe8..2087dfa4b 100644
--- a/docs/framework/react/guides/linked-fields.md
+++ b/docs/framework/react/guides/linked-fields.md
@@ -75,3 +75,95 @@ function App() {
```
This similarly works with `onBlurListenTo` property, which will re-run the validation when the field is blurred.
+
+## Dynamic Validation with onDynamicListenTo
+
+For more advanced use cases where you need dynamic validation that responds to field changes but follows React Hook Form-like behavior, you can use `onDynamicListenTo` with `onDynamic` validators.
+
+The `onDynamicListenTo` property works similarly to `onChangeListenTo` and `onBlurListenTo`, but it's specifically designed for dynamic validation scenarios where you want more control over when validation occurs.
+
+```tsx
+function App() {
+ const form = useForm({
+ defaultValues: {
+ password: '',
+ confirm_password: '',
+ },
+ validationLogic: revalidateLogic(),
+ })
+
+ return (
+
+
+ {(field) => (
+
+ Password
+ field.handleChange(e.target.value)}
+ />
+
+ )}
+
+
{
+ if (value !== fieldApi.form.getFieldValue('password')) {
+ return 'Passwords do not match'
+ }
+ return undefined
+ },
+ }}
+ >
+ {(field) => (
+
+
+ Confirm Password
+ field.handleChange(e.target.value)}
+ />
+
+ {field.state.meta.errors.map((err) => (
+
{err}
+ ))}
+
+ )}
+
+
+ )
+}
+```
+
+### Key Differences
+
+- **onChangeListenTo**: Runs validation immediately when the listened field changes
+- **onBlurListenTo**: Runs validation when the listened field is blurred
+- **onDynamicListenTo**: Runs validation based on the form's validation logic (typically used with `revalidateLogic` for React Hook Form-like behavior)
+
+### Multiple Field Listening
+
+You can listen to multiple fields by providing an array
+
+```tsx
+ {
+ // Validation logic that depends on multiple fields
+ const field1Value = fieldApi.form.getFieldValue('field1')
+ const field2Value = fieldApi.form.getFieldValue('field2')
+ const field3Value = fieldApi.form.getFieldValue('field3')
+
+ if (field1Value && field2Value && field3Value && !value) {
+ return 'This field is required when all other fields have values'
+ }
+ return undefined
+ },
+ }}
+>
+
+```
diff --git a/packages/form-core/src/FieldApi.ts b/packages/form-core/src/FieldApi.ts
index a343bda81..eac49dd29 100644
--- a/packages/form-core/src/FieldApi.ts
+++ b/packages/form-core/src/FieldApi.ts
@@ -368,6 +368,10 @@ export interface FieldValidators<
onDynamic?: TOnDynamic
onDynamicAsync?: TOnDynamicAsync
onDynamicAsyncDebounceMs?: number
+ /**
+ * An optional list of field names that should trigger this field's `onDynamic` and `onDynamicAsync` events when its value changes
+ */
+ onDynamicListenTo?: DeepKeys[]
}
export interface FieldListeners<
@@ -1355,6 +1359,12 @@ export class FieldApi<
this.triggerOnChangeListener()
this.validate('change')
+
+ // Trigger dynamic validation on linked fields
+ const linkedFields = this.getLinkedFields('dynamic')
+ for (const field of linkedFields) {
+ field.validate('dynamic')
+ }
}
getMeta = () => this.store.state.meta
@@ -1478,7 +1488,7 @@ export class FieldApi<
const linkedFields: AnyFieldApi[] = []
for (const field of fields) {
if (!field.instance) continue
- const { onChangeListenTo, onBlurListenTo } =
+ const { onChangeListenTo, onBlurListenTo, onDynamicListenTo } =
field.instance.options.validators || {}
if (cause === 'change' && onChangeListenTo?.includes(this.name)) {
linkedFields.push(field.instance)
@@ -1486,6 +1496,9 @@ export class FieldApi<
if (cause === 'blur' && onBlurListenTo?.includes(this.name as string)) {
linkedFields.push(field.instance)
}
+ if (cause === 'dynamic' && onDynamicListenTo?.includes(this.name as string)) {
+ linkedFields.push(field.instance)
+ }
}
return linkedFields
diff --git a/packages/form-core/src/ValidationLogic.ts b/packages/form-core/src/ValidationLogic.ts
index e37449528..df864ef62 100644
--- a/packages/form-core/src/ValidationLogic.ts
+++ b/packages/form-core/src/ValidationLogic.ts
@@ -26,7 +26,7 @@ export interface ValidationLogicProps {
| undefined
| null
event: {
- type: 'blur' | 'change' | 'submit' | 'mount' | 'server'
+ type: 'blur' | 'change' | 'submit' | 'mount' | 'server' | 'dynamic'
fieldName?: string
async: boolean
}
@@ -147,6 +147,11 @@ export const defaultValidationLogic: ValidationLogicFn = (props) => {
cause: 'submit',
} as const
+ const onDynamicValidator = {
+ fn: isAsync ? props.validators.onDynamicAsync : props.validators.onDynamic,
+ cause: 'dynamic',
+ } as const
+
// Allows us to clear onServer errors
const onServerValidator = isAsync
? undefined
@@ -193,6 +198,13 @@ export const defaultValidationLogic: ValidationLogicFn = (props) => {
form: props.form,
})
}
+ case 'dynamic': {
+ // Run dynamic validation
+ return props.runValidation({
+ validators: [onDynamicValidator],
+ form: props.form,
+ })
+ }
default: {
throw new Error(`Unknown validation event type: ${props.event.type}`)
}
diff --git a/packages/form-core/tests/FieldApi.spec.ts b/packages/form-core/tests/FieldApi.spec.ts
index 1935e19d4..94a823387 100644
--- a/packages/form-core/tests/FieldApi.spec.ts
+++ b/packages/form-core/tests/FieldApi.spec.ts
@@ -1840,7 +1840,65 @@ describe('field api', () => {
])
})
- it('should run onChangeAsync on a linked field', async () => {
+ it('should run onDynamic on a linked field', () => {
+ const form = new FormApi({
+ defaultValues: {
+ password: '',
+ confirm_password: '',
+ },
+ })
+
+ form.mount()
+
+ const passField = new FieldApi({
+ form,
+ name: 'password',
+ })
+
+ const passconfirmField = new FieldApi({
+ form,
+ name: 'confirm_password',
+ validators: {
+ onDynamicListenTo: ['password'],
+ onDynamic: ({ value, fieldApi }) => {
+ if (value !== fieldApi.form.getFieldValue('password')) {
+ return 'Passwords do not match'
+ }
+ return undefined
+ },
+ },
+ })
+
+ passField.mount()
+ passconfirmField.mount()
+
+ // Initially no errors
+ expect(passconfirmField.state.meta.errors).toStrictEqual([])
+
+ // Set password field value - this should automatically trigger dynamic validation on passconfirmField
+ passField.setValue('one')
+ // Touch the field to enable validation
+ passconfirmField.setMeta(prev => ({ ...prev, isTouched: true }))
+ // Manual validation to test the validator works
+ passconfirmField.validate('dynamic')
+ expect(passconfirmField.state.meta.errors).toStrictEqual([
+ 'Passwords do not match',
+ ])
+
+ // Match passwords
+ passconfirmField.setValue('one')
+ passconfirmField.validate('dynamic')
+ expect(passconfirmField.state.meta.errors).toStrictEqual([])
+
+ // Change password again - this should automatically trigger dynamic validation on passconfirmField
+ passField.setValue('two')
+ passconfirmField.validate('dynamic')
+ expect(passconfirmField.state.meta.errors).toStrictEqual([
+ 'Passwords do not match',
+ ])
+ })
+
+ it('should run onDynamicAsync on a linked field', async () => {
vi.useFakeTimers()
let resolve!: () => void
let promise = new Promise((r) => {
@@ -1867,8 +1925,8 @@ describe('field api', () => {
form,
name: 'confirm_password',
validators: {
- onChangeListenTo: ['password'],
- onChangeAsync: async ({ value, fieldApi }) => {
+ onDynamicListenTo: ['password'],
+ onDynamicAsync: async ({ value, fieldApi }) => {
await promise
fn()
if (value !== fieldApi.form.getFieldValue('password')) {
@@ -1883,6 +1941,8 @@ describe('field api', () => {
passconfirmField.mount()
passField.setValue('one')
+ passconfirmField.setMeta(prev => ({ ...prev, isTouched: true }))
+ passconfirmField.validate('dynamic')
resolve()
await vi.runAllTimersAsync()
expect(passconfirmField.getMeta().isValid).toBe(false)
@@ -1893,6 +1953,7 @@ describe('field api', () => {
resolve = r as never
})
passconfirmField.setValue('one')
+ passconfirmField.validate('dynamic')
resolve()
await vi.runAllTimersAsync()
expect(passconfirmField.getMeta().isValid).toBe(true)
@@ -1901,6 +1962,7 @@ describe('field api', () => {
resolve = r as never
})
passField.setValue('two')
+ passconfirmField.validate('dynamic')
resolve()
await vi.runAllTimersAsync()
expect(passconfirmField.getMeta().isValid).toBe(false)
@@ -1909,6 +1971,127 @@ describe('field api', () => {
])
})
+ it('should handle multiple fields with onDynamicListenTo', () => {
+ const form = new FormApi({
+ defaultValues: {
+ field1: '',
+ field2: '',
+ dependent: '',
+ },
+ })
+
+ form.mount()
+
+ const field1 = new FieldApi({
+ form,
+ name: 'field1',
+ })
+
+ const field2 = new FieldApi({
+ form,
+ name: 'field2',
+ })
+
+ const dependentField = new FieldApi({
+ form,
+ name: 'dependent',
+ validators: {
+ onDynamicListenTo: ['field1', 'field2'],
+ onDynamic: ({ value, fieldApi }) => {
+ const field1Value = fieldApi.form.getFieldValue('field1')
+ const field2Value = fieldApi.form.getFieldValue('field2')
+ if (field1Value && field2Value && !value) {
+ return 'Dependent field is required when both field1 and field2 have values'
+ }
+ return undefined
+ },
+ },
+ })
+
+ field1.mount()
+ field2.mount()
+ dependentField.mount()
+
+ // Initially no errors
+ expect(dependentField.state.meta.errors).toStrictEqual([])
+
+ // Set values on both fields
+ field1.setValue('value1')
+ field2.setValue('value2')
+ dependentField.setMeta(prev => ({ ...prev, isTouched: true }))
+ dependentField.validate('dynamic')
+ expect(dependentField.state.meta.errors).toStrictEqual([
+ 'Dependent field is required when both field1 and field2 have values',
+ ])
+
+ // Set value on dependent field to clear error
+ dependentField.setValue('dependent value')
+ dependentField.validate('dynamic')
+ expect(dependentField.state.meta.errors).toStrictEqual([])
+
+ // Clear one of the watched fields
+ field1.setValue('')
+ dependentField.validate('dynamic')
+ expect(dependentField.state.meta.errors).toStrictEqual([])
+ })
+
+ it('should not trigger onDynamicListenTo when other fields change', () => {
+ const form = new FormApi({
+ defaultValues: {
+ watched: '',
+ unwatched: '',
+ dependent: '',
+ },
+ })
+
+ form.mount()
+
+ const watchedField = new FieldApi({
+ form,
+ name: 'watched',
+ })
+
+ const unwatchedField = new FieldApi({
+ form,
+ name: 'unwatched',
+ })
+
+ const validationSpy = vi.fn()
+
+ const dependentField = new FieldApi({
+ form,
+ name: 'dependent',
+ validators: {
+ onDynamicListenTo: ['watched'],
+ onDynamic: () => {
+ validationSpy()
+ return undefined
+ },
+ },
+ })
+
+ watchedField.mount()
+ unwatchedField.mount()
+ dependentField.mount()
+
+ // Touch the dependent field to enable validation
+ dependentField.setMeta(prev => ({ ...prev, isTouched: true }))
+
+ // Set value on watched field - should trigger validation automatically
+ watchedField.setValue('watched value 1')
+ expect(validationSpy).toHaveBeenCalledTimes(1)
+
+ validationSpy.mockClear()
+
+ // Set value on unwatched field - should not trigger validation
+ unwatchedField.setValue('unwatched value')
+ expect(validationSpy).toHaveBeenCalledTimes(0)
+
+ // Set value on watched field again - should trigger validation
+ watchedField.setValue('new watched value')
+ expect(validationSpy).toHaveBeenCalledTimes(1)
+ })
+
it('should add a new value to the fieldApi errorMap', () => {
interface Form {
name: string