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>
<InputGroupInput
id={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 && (
<FieldError
className='text-xs text-left'
errors={errors.map((error) => ({ message: typeof error === 'string' ? error : error?.message }))}
/>
)}
{description && <FieldDescription className='text-xs'>{description}</FieldDescription>}
</Field>
);
}
Loading...

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: name autocompletes from your defaultValues on <form.AppField>
  • Tooltip integration: optional InfoIcon button with tooltip content via tooltip prop
  • 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.type is 'number' or 'range', or
  • the field's current value is a number (typically because defaultValues declares 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

Loading...
'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 availability
async 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 (
<form
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
className='space-y-4'
>
<form.AppField
name='username'
validators={{
// Sync validation: runs immediately
onChange: ({ 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 API
onChangeAsync: 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 calls
onChangeAsyncDebounceMs: 500,
}}
children={(field) => (
<field.InputField
label='Username'
description='Username will be checked for availability'
props={{ placeholder: 'Enter username' }}
/>
)}
/>
<form.AppField
name='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.InputField
label='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

Loading...
'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 (
<form
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
className='space-y-4'
>
<form.AppField
name='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.AppField
name='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

Loading...
'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 (
<form
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
className='space-y-6'
>
<div className='space-y-4'>
<h3 className='text-lg font-semibold'>Account</h3>
<form.AppField
name='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.AppField
name='user.profile.firstName'
validators={{
onChange: ({ value }) => (!value ? 'First name is required' : undefined),
}}
children={(field) => <field.InputField label='First Name' />}
/>
<form.AppField
name='user.profile.lastName'
validators={{
onChange: ({ value }) => (!value ? 'Last name is required' : undefined),
}}
children={(field) => <field.InputField label='Last Name' />}
/>
</div>
<form.AppField
name='user.profile.bio'
children={(field) => (
<field.InputField
label='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.AppField
name='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.AppField
name='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

Loading...
'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 (
<form
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
className='space-y-4'
>
<form.AppField
name='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.InputField
label='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.AppField
name='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.InputField
label='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.AppField
name='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.InputField
label='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

On this page