Input Field
Text input component integrated with TanStack Form via React context. Supports tooltips and InputGroup integration for addons and buttons.
npx shadcn@latest add https://shuip.plvo.dev/r/tsf-input-field.json
pnpm dlx shadcn@latest add https://shuip.plvo.dev/r/tsf-input-field.json
bun x shadcn@latest add https://shuip.plvo.dev/r/tsf-input-field.json
'use client';import { InfoIcon } from 'lucide-react';import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field';import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from '@/components/ui/input-group';import { useFieldContext } from '@/components/ui/shuip/tanstack-form/form-context';import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';export interface InputFieldProps {label?: string;description?: string;fieldProps?: React.ComponentProps<typeof Field>;props?: React.ComponentProps<'input'>;tooltip?: React.ReactNode;}export function InputField({ label, description, fieldProps, props, tooltip }: InputFieldProps) {const field = useFieldContext<string | number>();const { isValid, errors } = field.state.meta;const isNumeric = props?.type === 'number' || props?.type === 'range' || typeof field.state.value === 'number';return (<Field className='gap-2' data-invalid={!isValid} {...fieldProps}>{label && <FieldLabel htmlFor={field.name}>{label}</FieldLabel>}<InputGroup><InputGroupInputid={field.name}type={isNumeric ? 'number' : 'text'}name={field.name}value={typeof field.state.value === 'number' && Number.isNaN(field.state.value) ? '' : field.state.value}onChange={(e) => field.handleChange(isNumeric ? e.target.valueAsNumber : e.target.value)}onBlur={field.handleBlur}aria-invalid={!isValid}{...props}/>{tooltip && (<InputGroupAddon align='inline-end'><Tooltip><TooltipTrigger asChild><InputGroupButton aria-label='Info' size='icon-xs'><InfoIcon /></InputGroupButton></TooltipTrigger><TooltipContent>{tooltip}</TooltipContent></Tooltip></InputGroupAddon>)}</InputGroup>{!isValid && (<FieldErrorclassName='text-xs text-left'errors={errors.map((error) => ({ message: typeof error === 'string' ? error : error?.message }))}/>)}{description && <FieldDescription className='text-xs'>{description}</FieldDescription>}</Field>);}
InputField is a text input component that encapsulates TanStack Form's field management with shadcn/ui's design system. It handles all the boilerplate of connecting form state to an input element: wiring event handlers, displaying errors, managing touched states, and rendering consistent UI.
It reads the surrounding field via useFieldContext, so you compose it inside a <form.AppField> rather than passing a form instance down by prop.
Built-in features
- Context-bound field state: reads the field via
useFieldContext— no prop drilling - Type-safe field names:
nameautocompletes from yourdefaultValueson<form.AppField> - Tooltip integration: optional InfoIcon button with tooltip content via
tooltipprop - InputGroup ready: supports shadcn InputGroup for seamless addon integration
- Async validation: built-in debouncing and loading states for API validation
Setup
Field components are bound via React context. In your project, create lib/form.ts once:
// lib/form.ts
import { createFormHook } from '@tanstack/react-form';
import { fieldContext, formContext } from '@/components/ui/shuip/tanstack-form/form-context';
import { InputField } from '@/components/ui/shuip/tanstack-form/input-field';
import { SubmitButton } from '@/components/ui/shuip/tanstack-form/submit-button';
export const { useAppForm } = createFormHook({
fieldContext,
formContext,
fieldComponents: { InputField },
formComponents: { SubmitButton },
});See the form-context item for details.
Less boilerplate
TanStack Form's standard approach uses render props to access field state:
<form.Field
name='email'
validators={{
onChange: ({ value }) => !value.includes('@') ? 'Invalid email' : undefined,
}}
>
{(field) => (
<>
<label htmlFor={field.name}>Email</label>
<input
id={field.name}
name={field.name}
type='email'
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
/>
{!field.state.meta.isValid && (
<span>{field.state.meta.errors.join(', ')}</span>
)}
</>
)}
</form.Field>With InputField mounted inside <form.AppField>, the same field reduces to:
<form.AppField
name='email'
validators={{
onChange: ({ value }) => !value.includes('@') ? 'Invalid email' : undefined,
}}
children={(field) => (
<field.InputField label='Email' props={{ type: 'email' }} />
)}
/>The name and validators live on <form.AppField>; the wrapper just removes the inline JSX boilerplate for label, error display, and event wiring.
Common use cases
import { useAppForm } from '@/lib/form';
const form = useAppForm({
defaultValues: {
name: '',
email: '',
username: '',
age: 0,
},
onSubmit: async ({ value }) => {
await saveData(value);
},
});
// Basic text input
<form.AppField
name='name'
children={(field) => (
<field.InputField label='Name' description='Your full name' />
)}
/>
// Email with validation
<form.AppField
name='email'
validators={{
onChange: ({ value }) =>
!value.includes('@') ? 'Invalid email' : undefined,
}}
children={(field) => (
<field.InputField label='Email' props={{ type: 'email' }} />
)}
/>
// With tooltip
<form.AppField
name='username'
validators={{
onChange: ({ value }) =>
value.length < 3 ? 'Too short' : undefined,
}}
children={(field) => (
<field.InputField
label='Username'
tooltip='Username must be unique and 3-20 characters'
/>
)}
/>
// Numeric input — auto-detected because age is a number in defaultValues
<form.AppField
name='age'
children={(field) => (
<field.InputField label='Age' props={{ min: 0, max: 120 }} />
)}
/>Numeric fields
InputField handles both string and numeric fields. It detects numeric mode when either:
props.typeis'number'or'range', or- the field's current value is a
number(typically becausedefaultValuesdeclares it as one)
In numeric mode the input renders as type='number' and writes back e.target.valueAsNumber — empty input becomes NaN, which validators can check with Number.isNaN(value). In string mode it passes e.target.value through unchanged.
const form = useAppForm({
defaultValues: { quantity: 1, ratio: 0.5 },
validators: {
onChange: ({ value }) =>
Number.isNaN(value.quantity) ? { fields: { quantity: 'Required' } } : undefined,
},
});
<form.AppField name='quantity' children={(f) => <f.InputField label='Quantity' />} />
<form.AppField name='ratio' children={(f) => <f.InputField label='Ratio' props={{ type: 'range', min: 0, max: 1, step: 0.1 }} />} />If the field type is number | undefined / number | null and defaultValues starts as undefined/null, the runtime type check can't infer numeric mode — set props={{ type: 'number' }} explicitly in that case.
Examples
Async Validation
'use client';import { createFormHook } from '@tanstack/react-form';import { fieldContext, formContext } from '@/components/ui/shuip/tanstack-form/form-context';import { InputField } from '@/components/ui/shuip/tanstack-form/input-field';import { SubmitButton } from '@/components/ui/shuip/tanstack-form/submit-button';// Simulate API call to check username availabilityasync function checkUsernameAvailability(username: string): Promise<boolean> {await new Promise((resolve) => setTimeout(resolve, 1000));const takenUsernames = ['admin', 'user', 'test', 'demo'];return !takenUsernames.includes(username.toLowerCase());}const { useAppForm } = createFormHook({fieldContext,formContext,fieldComponents: { InputField },formComponents: { SubmitButton },});export default function TsfInputFieldAsyncValidationExample() {const form = useAppForm({defaultValues: {username: '',email: '',},onSubmit: async ({ value }) => {await new Promise((resolve) => setTimeout(resolve, 1000));alert(JSON.stringify(value, null, 2));},});return (<formonSubmit={(e) => {e.preventDefault();form.handleSubmit();}}className='space-y-4'><form.AppFieldname='username'validators={{// Sync validation: runs immediatelyonChange: ({ value }) => {if (!value) return 'Username is required';if (value.length < 3) return 'Username must be at least 3 characters';if (!/^[a-zA-Z0-9_]+$/.test(value)) return 'Only letters, numbers, and underscores allowed';return undefined;},// Async validation: checks availability via APIonChangeAsync: async ({ value }) => {if (!value || value.length < 3) return undefined;const available = await checkUsernameAvailability(value);return available ? undefined : 'Username is already taken';},// Debounce async validation to avoid excessive API callsonChangeAsyncDebounceMs: 500,}}children={(field) => (<field.InputFieldlabel='Username'description='Username will be checked for availability'props={{ placeholder: 'Enter username' }}/>)}/><form.AppFieldname='email'validators={{onBlur: ({ value }) => {if (!value) return 'Email is required';if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return 'Invalid email format';return undefined;},}}children={(field) => (<field.InputFieldlabel='Email'description='Email will be validated on blur'props={{ type: 'email', placeholder: 'your@email.com' }}/>)}/><form.AppForm><form.SubmitButton>Create Account</form.SubmitButton></form.AppForm></form>);}
Default
'use client';import { createFormHook } from '@tanstack/react-form';import { fieldContext, formContext } from '@/components/ui/shuip/tanstack-form/form-context';import { InputField } from '@/components/ui/shuip/tanstack-form/input-field';import { SubmitButton } from '@/components/ui/shuip/tanstack-form/submit-button';const { useAppForm } = createFormHook({fieldContext,formContext,fieldComponents: { InputField },formComponents: { SubmitButton },});export default function TsfInputFieldExample() {const form = useAppForm({defaultValues: {name: '',email: '',},onSubmit: async ({ value }) => {await new Promise((resolve) => setTimeout(resolve, 1000));alert(JSON.stringify(value, null, 2));},});return (<formonSubmit={(e) => {e.preventDefault();form.handleSubmit();}}className='space-y-4'><form.AppFieldname='name'validators={{onChange: ({ value }) => (value.length < 3 ? 'Name must be at least 3 characters' : undefined),}}children={(field) => <field.InputField label='Name' description='Your full name' />}/><form.AppFieldname='email'validators={{onChange: ({ value }) => (!value.includes('@') ? 'Invalid email address' : undefined),}}children={(field) => <field.InputField label='Email' props={{ type: 'email' }} />}/><form.AppForm><form.SubmitButton props={{ variant: 'outline' }}>Register</form.SubmitButton></form.AppForm></form>);}
Nested Path
'use client';import { createFormHook } from '@tanstack/react-form';import { fieldContext, formContext } from '@/components/ui/shuip/tanstack-form/form-context';import { InputField } from '@/components/ui/shuip/tanstack-form/input-field';import { SubmitButton } from '@/components/ui/shuip/tanstack-form/submit-button';const { useAppForm } = createFormHook({fieldContext,formContext,fieldComponents: { InputField },formComponents: { SubmitButton },});export default function TsfInputFieldNestedPathExample() {const form = useAppForm({defaultValues: {user: {email: '',profile: {firstName: '',lastName: '',bio: '',},address: {street: '',city: '',zipCode: '',},},},onSubmit: async ({ value }) => {await new Promise((resolve) => setTimeout(resolve, 1000));alert(JSON.stringify(value, null, 2));},});return (<formonSubmit={(e) => {e.preventDefault();form.handleSubmit();}}className='space-y-6'><div className='space-y-4'><h3 className='text-lg font-semibold'>Account</h3><form.AppFieldname='user.email'validators={{onChange: ({ value }) => {if (!value) return 'Email is required';if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return 'Invalid email format';return undefined;},}}children={(field) => <field.InputField label='Email Address' props={{ type: 'email' }} />}/></div><div className='space-y-4'><h3 className='text-lg font-semibold'>Profile</h3><div className='grid grid-cols-2 gap-4'><form.AppFieldname='user.profile.firstName'validators={{onChange: ({ value }) => (!value ? 'First name is required' : undefined),}}children={(field) => <field.InputField label='First Name' />}/><form.AppFieldname='user.profile.lastName'validators={{onChange: ({ value }) => (!value ? 'Last name is required' : undefined),}}children={(field) => <field.InputField label='Last Name' />}/></div><form.AppFieldname='user.profile.bio'children={(field) => (<field.InputFieldlabel='Bio'description='Tell us about yourself'props={{ placeholder: 'Software developer from...' }}/>)}/></div><div className='space-y-4'><h3 className='text-lg font-semibold'>Address</h3><form.AppFieldname='user.address.street'validators={{onChange: ({ value }) => (!value ? 'Street address is required' : undefined),}}children={(field) => <field.InputField label='Street Address' />}/><div className='grid grid-cols-2 gap-4'><form.AppField name='user.address.city' children={(field) => <field.InputField label='City' />} /><form.AppFieldname='user.address.zipCode'validators={{onChange: ({ value }) => {if (!value) return undefined;if (!/^\d{5}(-\d{4})?$/.test(value)) return 'Invalid ZIP code format';return undefined;},}}children={(field) => <field.InputField label='ZIP Code' props={{ placeholder: '12345' }} />}/></div></div><form.AppForm><form.SubmitButton>Save Profile</form.SubmitButton></form.AppForm></form>);}
Tooltip
'use client';import { createFormHook } from '@tanstack/react-form';import { fieldContext, formContext } from '@/components/ui/shuip/tanstack-form/form-context';import { InputField } from '@/components/ui/shuip/tanstack-form/input-field';import { SubmitButton } from '@/components/ui/shuip/tanstack-form/submit-button';const { useAppForm } = createFormHook({fieldContext,formContext,fieldComponents: { InputField },formComponents: { SubmitButton },});export default function TsfInputFieldTooltipExample() {const form = useAppForm({defaultValues: {apiKey: '',webhookUrl: '',secretKey: '',},onSubmit: async ({ value }) => {await new Promise((resolve) => setTimeout(resolve, 1000));alert(JSON.stringify(value, null, 2));},});return (<formonSubmit={(e) => {e.preventDefault();form.handleSubmit();}}className='space-y-4'><form.AppFieldname='apiKey'validators={{onChange: ({ value }) => {if (!value) return 'API key is required';if (!value.startsWith('sk_')) return 'API key must start with sk_';if (value.length < 20) return 'API key is too short';return undefined;},}}children={(field) => (<field.InputFieldlabel='API Key'description='Your application API key'tooltip={<><p className='font-semibold mb-1'>Where to find your API key:</p><ol className='list-decimal list-inside space-y-1 text-sm'><li>Go to Settings → API</li><li>Click "Generate New Key"</li><li>Copy the key (it will only be shown once)</li></ol></>}props={{ placeholder: 'sk_live_...' }}/>)}/><form.AppFieldname='webhookUrl'validators={{onChange: ({ value }) => {if (!value) return 'Webhook URL is required';if (!value.startsWith('https://')) return 'Webhook URL must use HTTPS';try {new URL(value);return undefined;} catch {return 'Invalid URL format';}},}}children={(field) => (<field.InputFieldlabel='Webhook URL'description='Endpoint to receive webhook events'tooltip='This URL must be publicly accessible and accept POST requests. We recommend using HTTPS for security.'props={{ type: 'url', placeholder: 'https://api.example.com/webhooks' }}/>)}/><form.AppFieldname='secretKey'validators={{onChange: ({ value }) => {if (!value) return 'Webhook secret is required';if (value.length < 16) return 'Secret must be at least 16 characters';return undefined;},}}children={(field) => (<field.InputFieldlabel='Webhook Secret'description='Used to verify webhook signatures'tooltip='Keep this secret safe. It will be used to sign webhook payloads so you can verify their authenticity.'props={{ placeholder: 'whsec_...' }}/>)}/><form.AppForm><form.SubmitButton>Save Configuration</form.SubmitButton></form.AppForm></form>);}
Props
Prop
Type