Skip to content
Open
29 changes: 29 additions & 0 deletions examples/react/simple/src/index.tsx
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if adding this to the simple example makes sense, since

  • It's not a simple implementation
  • It's unrelated to the other fields

Having linked fields as examples is a good idea though. Maybe it could be its own example? Perhaps it can be added to large-form instead. I'll leave it up to you.

Copy link
Contributor Author

@jiji-hoon96 jiji-hoon96 Dec 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe it makes more sense to include it in large-form rather than as a separate example.
Linked field validation is a pattern commonly used in real-world scenarios, so it fits naturally within that example.
I'll move it to large-form. Is that okay with you?

Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ export default function App() {
defaultValues: {
firstName: '',
lastName: '',
password: '',
confirmPassword: '',
},
onSubmit: async ({ value }) => {
// Do something with form data
Expand Down Expand Up @@ -95,6 +97,33 @@ export default function App() {
)}
/>
</div>
<div>
<form.Field
name="password"
validators={{
onChange: ({ value }) =>
!value
? 'A password is required'
: value.length < 6
? 'Password must be at least 6 characters'
: undefined,
}}
children={(field) => (
<>
<label htmlFor={field.name}>Password:</label>
<input
id={field.name}
name={field.name}
type="password"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
<FieldInfo field={field} />
</>
)}
/>
</div>
<form.Subscribe
selector={(state) => [state.canSubmit, state.isSubmitting]}
children={([canSubmit, isSubmitting]) => (
Expand Down
117 changes: 98 additions & 19 deletions packages/form-core/src/FieldApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TParentData>[]
}

export interface FieldListeners<
Expand Down Expand Up @@ -1533,16 +1537,30 @@ export class FieldApi<
getLinkedFields = (cause: ValidationCause) => {
const fields = Object.values(this.form.fieldInfo) as FieldInfo<any>[]

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',
})
}
}

Expand All @@ -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)
Expand Down Expand Up @@ -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)
}
})

Expand Down Expand Up @@ -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 }
}

Expand Down Expand Up @@ -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)
Expand All @@ -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 }))
}

Expand Down Expand Up @@ -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,
)
}
Expand All @@ -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 }))
}

Expand Down
7 changes: 6 additions & 1 deletion packages/form-core/src/FieldGroupApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,9 @@ export class FieldGroupApi<

if (
validators &&
(validators.onChangeListenTo || validators.onBlurListenTo)
(validators.onChangeListenTo ||
validators.onBlurListenTo ||
validators.onDynamicListenTo)
) {
const newValidators = { ...validators }

Expand All @@ -219,6 +221,9 @@ export class FieldGroupApi<
validators.onChangeListenTo,
)
newValidators.onBlurListenTo = remapListenTo(validators.onBlurListenTo)
newValidators.onDynamicListenTo = remapListenTo(
validators.onDynamicListenTo,
)

newProps.validators = newValidators
}
Expand Down
14 changes: 13 additions & 1 deletion packages/form-core/src/ValidationLogic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
})
}
Comment on lines +201 to +207
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assumed the point of onDynamic was that it would be dictated by validationLogic. However, it now needs to be addressed in revalidateLogic. I feel like there's some underlying issue that should be fixed, but I'll need to look at it myself to know for sure.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there's an architectural issue here too.

onDynamic is triggered by other fields' changes (via onDynamicListenTo), not by the standard validation event flow. This forces both defaultValidationLogic and revalidateLogic to handle it specially.

@LeCarbonator Should onDynamic be a first-class event type, or just metadata that says "run this validator when linked fields change"?

I'm wait for your review on the best approach.

default: {
throw new Error(`Unknown validation event type: ${props.event.type}`)
}
Expand Down
Loading
Loading