From 75a0fc99c7ff125dc40f71f85ab302b902ae1483 Mon Sep 17 00:00:00 2001 From: JIHOON LEE Date: Thu, 21 Aug 2025 13:39:30 +0900 Subject: [PATCH] feat: add onDynamicListenTo for dynamic field validation with React Hook Form-like behavior --- docs/framework/react/guides/linked-fields.md | 92 +++++++++ packages/form-core/src/FieldApi.ts | 15 +- packages/form-core/src/ValidationLogic.ts | 14 +- packages/form-core/tests/FieldApi.spec.ts | 189 ++++++++++++++++++- 4 files changed, 305 insertions(+), 5 deletions(-) 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) => ( + + )} + + { + if (value !== fieldApi.form.getFieldValue('password')) { + return 'Passwords do not match' + } + return undefined + }, + }} + > + {(field) => ( +
+ + {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