diff --git a/examples/react/simple/src/index.tsx b/examples/react/simple/src/index.tsx
index 4228f1e0e..9457d2d34 100644
--- a/examples/react/simple/src/index.tsx
+++ b/examples/react/simple/src/index.tsx
@@ -23,6 +23,8 @@ export default function App() {
defaultValues: {
firstName: '',
lastName: '',
+ password: '',
+ confirmPassword: '',
},
onSubmit: async ({ value }) => {
// Do something with form data
@@ -95,6 +97,33 @@ export default function App() {
)}
/>
+
+
+ !value
+ ? 'A password is required'
+ : value.length < 6
+ ? 'Password must be at least 6 characters'
+ : undefined,
+ }}
+ children={(field) => (
+ <>
+
+ field.handleChange(e.target.value)}
+ />
+
+ >
+ )}
+ />
+
[state.canSubmit, state.isSubmitting]}
children={([canSubmit, isSubmitting]) => (
diff --git a/packages/form-core/src/FieldApi.ts b/packages/form-core/src/FieldApi.ts
index 7a97afe71..591c46eda 100644
--- a/packages/form-core/src/FieldApi.ts
+++ b/packages/form-core/src/FieldApi.ts
@@ -370,6 +370,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<
@@ -1533,16 +1537,30 @@ export class FieldApi<
getLinkedFields = (cause: ValidationCause) => {
const fields = Object.values(this.form.fieldInfo) as FieldInfo[]
- const linkedFields: AnyFieldApi[] = []
+ const linkedFields: Array<{
+ field: AnyFieldApi
+ validatorCause: ValidationCause
+ validatorType?: 'dynamic'
+ }> = []
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)
+ linkedFields.push({ field: field.instance, validatorCause: 'change' })
}
if (cause === 'blur' && onBlurListenTo?.includes(this.name as string)) {
- linkedFields.push(field.instance)
+ linkedFields.push({ field: field.instance, validatorCause: 'blur' })
+ }
+ if (
+ (cause === 'change' || cause === 'blur') &&
+ onDynamicListenTo?.includes(this.name as string)
+ ) {
+ linkedFields.push({
+ field: field.instance,
+ validatorCause: cause,
+ validatorType: 'dynamic',
+ })
}
}
@@ -1565,14 +1583,34 @@ export class FieldApi<
const linkedFields = this.getLinkedFields(cause)
const linkedFieldValidates = linkedFields.reduce(
- (acc, field) => {
- const fieldValidates = getSyncValidatorArray(cause, {
- ...field.options,
+ (acc, { field, validatorCause, validatorType }) => {
+ let fieldOptions = field.options
+
+ if (validatorType === 'dynamic' && field.options.validators) {
+ const modifiedValidators = { ...field.options.validators }
+
+ if (validatorCause === 'change') {
+ modifiedValidators.onChange = modifiedValidators.onDynamic
+ } else if (validatorCause === 'blur') {
+ modifiedValidators.onBlur = modifiedValidators.onDynamic
+ }
+
+ fieldOptions = {
+ ...field.options,
+ validators: modifiedValidators,
+ }
+ }
+
+ const fieldValidates = getSyncValidatorArray(validatorCause, {
+ ...fieldOptions,
form: field.form,
validationLogic:
field.form.options.validationLogic || defaultValidationLogic,
})
fieldValidates.forEach((validate) => {
+ if (validatorType === 'dynamic') {
+ validate.cause = 'dynamic'
+ }
;(validate as any).field = field
})
return acc.concat(fieldValidates as never)
@@ -1638,9 +1676,9 @@ export class FieldApi<
for (const validateObj of validates) {
validateFieldFn(this, validateObj)
}
- for (const fieldValitateObj of linkedFieldValidates) {
- if (!fieldValitateObj.validate) continue
- validateFieldFn(fieldValitateObj.field, fieldValitateObj)
+ for (const fieldValidateObj of linkedFieldValidates) {
+ if (!fieldValidateObj.validate) continue
+ validateFieldFn(fieldValidateObj.field, fieldValidateObj)
}
})
@@ -1669,6 +1707,27 @@ export class FieldApi<
}))
}
+ const dynamicErrKey = getErrorMapKey('dynamic')
+
+ if (
+ this.state.meta.errorMap[dynamicErrKey] &&
+ this.state.meta.errorSourceMap[dynamicErrKey] === 'field' &&
+ cause !== 'dynamic' &&
+ !hasErrored
+ ) {
+ this.setMeta((prev) => ({
+ ...prev,
+ errorMap: {
+ ...prev.errorMap,
+ [dynamicErrKey]: undefined,
+ },
+ errorSourceMap: {
+ ...prev.errorSourceMap,
+ [dynamicErrKey]: undefined,
+ },
+ }))
+ }
+
return { hasErrored }
}
@@ -1704,14 +1763,34 @@ export class FieldApi<
const linkedFields = this.getLinkedFields(cause)
const linkedFieldValidates = linkedFields.reduce(
- (acc, field) => {
- const fieldValidates = getAsyncValidatorArray(cause, {
- ...field.options,
+ (acc, { field, validatorCause, validatorType }) => {
+ let fieldOptions = field.options
+
+ if (validatorType === 'dynamic' && field.options.validators) {
+ const modifiedValidators = { ...field.options.validators }
+
+ if (validatorCause === 'change') {
+ modifiedValidators.onChangeAsync = modifiedValidators.onDynamicAsync
+ } else if (validatorCause === 'blur') {
+ modifiedValidators.onBlurAsync = modifiedValidators.onDynamicAsync
+ }
+
+ fieldOptions = {
+ ...field.options,
+ validators: modifiedValidators,
+ }
+ }
+
+ const fieldValidates = getAsyncValidatorArray(validatorCause, {
+ ...fieldOptions,
form: field.form,
validationLogic:
field.form.options.validationLogic || defaultValidationLogic,
})
fieldValidates.forEach((validate) => {
+ if (validatorType === 'dynamic') {
+ validate.cause = 'dynamic'
+ }
;(validate as any).field = field
})
return acc.concat(fieldValidates as never)
@@ -1727,7 +1806,7 @@ export class FieldApi<
this.setMeta((prev) => ({ ...prev, isValidating: true }))
}
- for (const linkedField of linkedFields) {
+ for (const { field: linkedField } of linkedFields) {
linkedField.setMeta((prev) => ({ ...prev, isValidating: true }))
}
@@ -1825,11 +1904,11 @@ export class FieldApi<
if (!validateObj.validate) continue
validateFieldAsyncFn(this, validateObj, validatesPromises)
}
- for (const fieldValitateObj of linkedFieldValidates) {
- if (!fieldValitateObj.validate) continue
+ for (const fieldValidateObj of linkedFieldValidates) {
+ if (!fieldValidateObj.validate) continue
validateFieldAsyncFn(
- fieldValitateObj.field,
- fieldValitateObj,
+ fieldValidateObj.field,
+ fieldValidateObj,
linkedPromises,
)
}
@@ -1842,7 +1921,7 @@ export class FieldApi<
this.setMeta((prev) => ({ ...prev, isValidating: false }))
- for (const linkedField of linkedFields) {
+ for (const { field: linkedField } of linkedFields) {
linkedField.setMeta((prev) => ({ ...prev, isValidating: false }))
}
diff --git a/packages/form-core/src/FieldGroupApi.ts b/packages/form-core/src/FieldGroupApi.ts
index 0bc575d67..e153fe5c8 100644
--- a/packages/form-core/src/FieldGroupApi.ts
+++ b/packages/form-core/src/FieldGroupApi.ts
@@ -204,7 +204,9 @@ export class FieldGroupApi<
if (
validators &&
- (validators.onChangeListenTo || validators.onBlurListenTo)
+ (validators.onChangeListenTo ||
+ validators.onBlurListenTo ||
+ validators.onDynamicListenTo)
) {
const newValidators = { ...validators }
@@ -219,6 +221,9 @@ export class FieldGroupApi<
validators.onChangeListenTo,
)
newValidators.onBlurListenTo = remapListenTo(validators.onBlurListenTo)
+ newValidators.onDynamicListenTo = remapListenTo(
+ validators.onDynamicListenTo,
+ )
newProps.validators = newValidators
}
diff --git a/packages/form-core/src/ValidationLogic.ts b/packages/form-core/src/ValidationLogic.ts
index e37449528..2d358936a 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, server validation
+ return props.runValidation({
+ validators: [onDynamicValidator, onServerValidator],
+ 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 320ac1aea..1eaae67ca 100644
--- a/packages/form-core/tests/FieldApi.spec.ts
+++ b/packages/form-core/tests/FieldApi.spec.ts
@@ -1908,6 +1908,127 @@ describe('field api', () => {
])
})
+ 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'],
+ onChange: ({ value, fieldApi }) => {
+ if (value !== fieldApi.form.getFieldValue('password')) {
+ return 'Passwords do not match'
+ }
+ return undefined
+ },
+ onDynamic: ({ value, fieldApi }) => {
+ if (value !== fieldApi.form.getFieldValue('password')) {
+ return 'Passwords do not match'
+ }
+ return undefined
+ },
+ },
+ })
+
+ passField.mount()
+ passconfirmField.mount()
+
+ passField.setValue('one')
+ expect(passconfirmField.getMeta().isValid).toBe(false)
+ expect(passconfirmField.state.meta.errors).toStrictEqual([
+ 'Passwords do not match',
+ ])
+ passconfirmField.setValue('one')
+ expect(passconfirmField.getMeta().isValid).toBe(true)
+ expect(passconfirmField.state.meta.errors).toStrictEqual([])
+ passField.setValue('two')
+ expect(passconfirmField.getMeta().isValid).toBe(false)
+ expect(passconfirmField.state.meta.errors).toStrictEqual([
+ 'Passwords do not match',
+ ])
+ })
+
+ it('should run onDynamicAsync on a linked field', async () => {
+ vi.useFakeTimers()
+
+ const fn = vi.fn()
+
+ 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'],
+ onChangeAsync: async ({ value, fieldApi }) => {
+ await new Promise((resolve) => setTimeout(resolve, 100))
+ fn()
+ if (value !== fieldApi.form.getFieldValue('password')) {
+ return 'Passwords do not match'
+ }
+ return undefined
+ },
+ onDynamicAsync: async ({ value, fieldApi }) => {
+ await new Promise((resolve) => setTimeout(resolve, 100))
+ fn()
+ if (value !== fieldApi.form.getFieldValue('password')) {
+ return 'Passwords do not match'
+ }
+ return undefined
+ },
+ },
+ })
+
+ passField.mount()
+ passconfirmField.mount()
+
+ passField.setValue('one')
+ await vi.advanceTimersByTimeAsync(100)
+ expect(passconfirmField.getMeta().isValid).toBe(false)
+ expect(passconfirmField.state.meta.errors).toStrictEqual([
+ 'Passwords do not match',
+ ])
+
+ passconfirmField.setValue('one')
+ await vi.advanceTimersByTimeAsync(100)
+ expect(passconfirmField.getMeta().isValid).toBe(true)
+ expect(passconfirmField.state.meta.errors).toStrictEqual([])
+
+ passField.setValue('two')
+ await vi.advanceTimersByTimeAsync(100)
+ expect(passconfirmField.getMeta().isValid).toBe(false)
+ expect(passconfirmField.state.meta.errors).toStrictEqual([
+ 'Passwords do not match',
+ ])
+
+ vi.useRealTimers()
+ })
+
it('should add a new value to the fieldApi errorMap', () => {
interface Form {
name: string