Execution contract
- Use when
- Use when field validity depends on server facts such as a unique name or invite code and early feedback is useful before final submission.
- Avoid when
- Keep required, length, and format rules synchronous; do not turn every rule into a network request for uniformity.
- Preconditions
- Define synchronous guards, async trigger timing, debounce, normalization, network-failure messaging, and equivalent submission-time server validation.
- Verification
- Use fake timers and controlled responses to test rapid input, blur, duplicate values, network failure, and submit; invalid input sends no request and final UI matches the current value.
- Failure mode
- A request per keystroke usually means debounce is configured at the wrong level; if an old error overwrites a new value, inspect custom request code for stale response handling.
- Security
- Preflight validation can reveal account or resource existence; minimize responses, rate-limit, assess enumeration risk, and still authenticate, authorize, and validate the final submission.
Only server facts need async validation
Required, length, and format rules belong in synchronous onChange or onBlur validators. Only remote facts such as name availability or invite validity need onChangeAsync or onBlurAsync. Synchronous validation runs first by default, so obvious invalid input does not reach the network.
Configure debounce at the validator boundary
onChangeAsyncDebounceMs delays only its async validator, not local value updates. The field stays immediately controlled while avoiding a request per keystroke. Errors may be strings or custom structures, but one form should keep a stable error shape.
<form.Field name="title" validators={{
onChange: ({ value }) =>
value.trim().length >= 3 ? undefined : 'Use at least 3 characters',
onChangeAsyncDebounceMs: 500,
onChangeAsync: async ({ value }) =>
await isIssueTitleTaken(value.trim()) ? 'Title is already in use' : undefined,
}}>
{(field) => <label>Title<input name={field.name} value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)} />
{field.state.meta.isTouched && !field.state.meta.isValid
? <span role="alert">{field.state.meta.errors.join(', ')}</span> : null}
</label>}
</form.Field>Submission remains the final validation point
canSubmit and isSubmitting drive feedback and duplicate-action protection but are not trust boundaries. onSubmit must call a write endpoint that parses, authenticates, and authorizes on the server. Uniqueness can change between preflight validation and submission, so server conflicts must return an error the current form can represent.
Preflight validation can enable enumeration
“Email already registered” or “resource exists” may disclose sensitive facts. Depending on the domain, use generic responses, authenticated checks, rate limits, or no preflight check. Distinguish network failure from domain invalidity instead of reporting service outage as a bad field.