Implement an issue form with inferred field names, intentional error timing, and duplicate-submit protection.
Default values establish the form input type
useForm infers field paths and the onSubmit value from defaultValues. Each Field reads field.state.value and advances state through field.handleChange and field.handleBlur. Do not mix local React state, DOM defaultValue, and Form-controlled values or you create multiple sources of truth.
Separate validation timing from error presentation
onBlur avoids showing errors for every keystroke, while onChange suits immediate constraints. Validators return a truthy error on failure and undefined on success. Errors may exist in field meta, but presentation should usually also check isTouched so the initial render does not blame the user.
import { useForm } from '@tanstack/react-form'
export function IssueForm() {
const form = useForm({
defaultValues: { title: '', priority: 'medium' as 'low' | 'medium' | 'high' },
onSubmit: async ({ value }) => { await createIssue(value) },
})
return <form onSubmit={(event) => { event.preventDefault(); form.handleSubmit() }}>
<form.Field name="title" validators={{
onBlur: ({ value }) => value.trim() ? undefined : 'Title is required',
}}>{(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>
<form.Subscribe selector={(state) => [state.canSubmit, state.isSubmitting]}>
{([canSubmit, isSubmitting]) => <button type="submit"
aria-disabled={!canSubmit || isSubmitting} disabled={isSubmitting}>
{isSubmitting ? 'Saving…' : 'Create issue'}
</button>}
</form.Subscribe>
</form>
}Submission state is not a server trust boundary
Prevent the native submit and call form.handleSubmit; onSubmit runs only after validation passes. isSubmitting covers the asynchronous handler and can prevent duplicate actions while showing progress. Client types and validators are bypassable, so a Server Function or Server Route must parse and authorize again, returning distinguishable field and system errors.
Turn this lesson into a verifiable skill
Field names, defaults, validators, and onSubmit values share one type, with errors shown at an intentional interaction point.
Build an issue form with title and priority: validate title on blur, expose submission progress, and prevent duplicate submits.
Test an empty title, valid submit, async failure, and rapid double-click; invalid values never reach onSubmit, only one request runs while pending, and the server validates independently.
- Passing both defaultValue and a controlled value to the input.
- Trusting browser validation and writing the raw value directly on the server.