Overview
TanStack Form is a headless form library that provides powerful type-safe form state management. These components integrate TanStack Form with shuip's field components for a streamlined development experience.
What is this?
TanStack Form is a headless UI library for building forms with strong TypeScript support and framework-agnostic validation. Unlike traditional form libraries that couple form state with UI components, TanStack Form handles only the state management—leaving you free to choose your UI layer.
These shuip components bridge TanStack Form with shadcn/ui, providing pre-built field components that reduce boilerplate and speed up development. They handle the repetitive setup work while preserving TanStack Form's type safety and validation capabilities.
How it works under the hood
When you use a field component like InputField
, it:
- Creates a
form.Field
instance for your field name - Accesses field state via
field.state.value
andfield.state.meta
- Wires up event handlers (
field.handleChange
,field.handleBlur
) - Renders shadcn UI components (
Field
,FieldLabel
,FieldError
, etc.) - Manages validation through the
formProps.validators
configuration
The field's state.meta
object contains:
errors
: array of validation error messagesisTouched
: whether the user has interacted with the fieldisDirty
: whether the value differs from initialisValidating
: whether async validation is in progressisValid
: whether all validators pass
Integration with shadcn/ui
All field components are built on shadcn's primitives:
Field
: Wrapper component that manages invalid statesFieldLabel
,FieldDescription
,FieldError
: Consistent field UIInputGroup
: For password toggle buttons, tooltips, and addonsSelect
,RadioGroup
,Checkbox
: Radix UI form primitives
This ensures visual consistency with your shadcn-based application while adding TanStack Form's powerful state management.
Type safety
Full TypeScript support with automatic type inference:
type UserForm = {user: {email: stringprofile: {age: number}}}const form = useForm<UserForm>({defaultValues: {user: { email: '', profile: { age: 0 } }}})// TypeScript autocompletes nested paths: 'user.email', 'user.profile.age'<InputField form={form} name='user.email' label='Email' /><InputField form={form} name='user.profile.age' label='Age' props={{ type: 'number' }} />
The name
prop uses DeepKeys<TFormData>
for compile-time validation of field paths, and values are typed as DeepValue<TFormData, TName>
for correct inference.
Validation system
TanStack Form supports multiple validation strategies:
<InputFieldform={form}name='email'label='Email'formProps={{validators: {// Runs on every changeonChange: ({ value }) =>!value.includes('@') ? 'Invalid email' : undefined,// Runs when field loses focusonBlur: ({ value }) =>!value ? 'Email is required' : undefined,// Async validation with debounceonChangeAsync: async ({ value }) => {const available = await checkEmailAvailability(value)return available ? undefined : 'Email already taken'},onChangeAsyncDebounceMs: 500,// Runs on form submissiononSubmit: ({ value }) =>!value ? 'This field is required' : undefined}}}/>
Validators receive { value, fieldApi }
and must return undefined
for valid or an error string for invalid.
Linked field validation
Use onChangeListenTo
to validate one field based on another's value:
<PasswordFieldform={form}name='password'label='Password'/><PasswordFieldform={form}name='confirmPassword'label='Confirm Password'formProps={{validators: {onChangeListenTo: ['password'],onChange: ({ value, fieldApi }) => {const password = fieldApi.form.getFieldValue('password')return value !== password ? 'Passwords do not match' : undefined}}}}/>
When password
changes, confirmPassword
is automatically re-validated.
Form submission
Handle form submission with the onSubmit
callback:
const form = useForm({defaultValues: { email: '', password: '' },onSubmit: async ({ value }) => {// value is fully typed based on defaultValuesconst response = await fetch('/api/login', {method: 'POST',body: JSON.stringify(value),})if (!response.ok) {// Handle server errors}},// Optional: handle submit errorsonSubmitInvalid: ({ formApi }) => {toast.error('Please fix form errors')}})
Use <SubmitButton form={form}>
to get automatic loading states and disabled handling.