Select Field

Dropdown select component integrated with TanStack Form. Options are defined as Record<string, string> where keys are labels.

npx shadcn@latest add https://shuip.plvo.dev/r/tsf-select-field.json
pnpm dlx shadcn@latest add https://shuip.plvo.dev/r/tsf-select-field.json
bun x shadcn@latest add https://shuip.plvo.dev/r/tsf-select-field.json
import type {
DeepKeys,
DeepValue,
FieldAsyncValidateOrFn,
FieldOptions,
FieldValidateOrFn,
FormAsyncValidateOrFn,
FormValidateOrFn,
ReactFormApi,
} from '@tanstack/react-form';
import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
/**
* Key is the label, value is the value
* @example
* const options: SelectFieldOption = {
* 'First': '1',
* 'Second': '2',
* 'Third': '3',
* };
*/
export type SelectFieldOption = Record<string, string>;
export interface SelectFieldProps<
TFormData,
TName extends DeepKeys<TFormData>,
TData extends DeepValue<TFormData, TName> = DeepValue<TFormData, TName>,
> {
form: ReactFormApi<
TFormData,
undefined | FormValidateOrFn<TFormData>,
undefined | FormValidateOrFn<TFormData>,
undefined | FormAsyncValidateOrFn<TFormData>,
undefined | FormValidateOrFn<TFormData>,
undefined | FormAsyncValidateOrFn<TFormData>,
undefined | FormValidateOrFn<TFormData>,
undefined | FormAsyncValidateOrFn<TFormData>,
undefined | FormValidateOrFn<TFormData>,
undefined | FormAsyncValidateOrFn<TFormData>,
undefined | FormAsyncValidateOrFn<TFormData>,
any
>;
name: TName;
options: SelectFieldOption;
label?: string;
placeholder?: string;
description?: string;
formProps?: Partial<
FieldOptions<
TFormData,
TName,
TData,
undefined | FieldValidateOrFn<TFormData, TName, TData>,
undefined | FieldValidateOrFn<TFormData, TName, TData>,
undefined | FieldAsyncValidateOrFn<TFormData, TName, TData>,
undefined | FieldValidateOrFn<TFormData, TName, TData>,
undefined | FieldAsyncValidateOrFn<TFormData, TName, TData>,
undefined | FieldValidateOrFn<TFormData, TName, TData>,
undefined | FieldAsyncValidateOrFn<TFormData, TName, TData>,
undefined | FieldValidateOrFn<TFormData, TName, TData>,
undefined | FieldAsyncValidateOrFn<TFormData, TName, TData>
>
>;
fieldProps?: React.ComponentProps<typeof Field>;
props?: React.ComponentProps<typeof Select>;
}
export function SelectField<
TFormData,
TName extends DeepKeys<TFormData>,
TData extends DeepValue<TFormData, TName> = DeepValue<TFormData, TName>,
>({
form,
name,
options,
label,
description,
placeholder,
formProps,
fieldProps,
props,
}: SelectFieldProps<TFormData, TName, TData>) {
return (
<form.Field name={name} {...formProps}>
{(field) => {
const { isValid, errors } = field.state.meta;
return (
<Field className='gap-2' data-invalid={!isValid} {...fieldProps}>
{label && <FieldLabel>{label}</FieldLabel>}
<Select
name={field.name}
value={field.state.value as string}
onValueChange={(value) => field.handleChange(value as TData)}
{...props}
>
<SelectTrigger aria-invalid={!isValid}>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{Object.entries(options).map(([label, value]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
{!isValid && (
<FieldError className='text-xs text-left' errors={errors.map((error) => ({ message: error }))} />
)}
{description && <FieldDescription className='text-xs'>{description}</FieldDescription>}
</Field>
);
}}
</form.Field>
);
}
Loading...

Dropdowns present a list of options in a compact UI. SelectField uses Radix UI's Select primitive wrapped with TanStack Form state management. The options prop accepts a Record<string, string> where keys are the display labels and values are what gets stored in the form state.

This format makes it simple to define options inline or map from API responses. For dependent selects (e.g., country → states), use useStore to watch one field and update another field's options dynamically with setFieldValue.

Built-in features

  • Record-based options for clean label/value mapping
  • Dynamic options via state or API data
  • Dependent selects with form.useStore and setFieldValue
  • Empty state placeholder via placeholder prop

Options format

const options = {
    'Display Label 1': 'value1',
    'Display Label 2': 'value2',
}

<SelectField
    form={form}
    name='choice'
    options={options}
    label='Select an option'
    placeholder='Choose...'
/>

Dependent selects

const [states, setStates] = React.useState<Record<string, string>>({})

// Watch country changes
form.useStore((state) => state.values.country, {
    onChange: (country) => {
      setStates(country === 'us' ? { 'California': 'ca', 'Texas': 'tx' } : {})
      form.setFieldValue('state', '') // Reset state field
    }
})

<SelectField form={form} name='country' options={{ 'United States': 'us' }} label='Country' />
<SelectField form={form} name='state' options={states} label='State' placeholder='Select country first' />

Examples

Conditional

Loading...
'use client';
import { useForm, useStore } from '@tanstack/react-form';
import { InputField } from '@/components/ui/shuip/tanstack-form/input-field';
import { SelectField } from '@/components/ui/shuip/tanstack-form/select-field';
import { SubmitButton } from '@/components/ui/shuip/tanstack-form/submit-button';
export default function TsfSelectFieldConditionalExample() {
const form = useForm({
defaultValues: {
accountType: '',
businessName: '',
companySize: '',
},
onSubmit: async ({ value }) => {
await new Promise((resolve) => setTimeout(resolve, 1000));
alert(JSON.stringify(value, null, 2));
},
});
const accountType = useStore(form.store, (state) => state.values.accountType);
return (
<form
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
className='space-y-4'
>
<SelectField
form={form}
name='accountType'
options={{
Personal: 'personal',
Business: 'business',
}}
label='Account Type'
placeholder='Select account type'
formProps={{
validators: {
onChange: ({ value }) => (!value ? 'Please select an account type' : undefined),
},
}}
/>
{/* Show business fields only when Business is selected */}
{accountType === 'business' && (
<>
<InputField
form={form}
name='businessName'
label='Business Name'
formProps={{
validators: {
onChange: ({ value }) => (!value ? 'Business name is required' : undefined),
},
}}
/>
<SelectField
form={form}
name='companySize'
options={{
'1-10 employees': '1-10',
'11-50 employees': '11-50',
'51-200 employees': '51-200',
'201-1000 employees': '201-1000',
'1000+ employees': '1000+',
}}
label='Company Size'
placeholder='Select company size'
formProps={{
validators: {
onChange: ({ value }) => (!value ? 'Please select company size' : undefined),
},
}}
/>
</>
)}
<SubmitButton form={form}>Create Account</SubmitButton>
</form>
);
}

Dependent

Loading...
'use client';
import { useForm } from '@tanstack/react-form';
import React from 'react';
import { SelectField } from '@/components/ui/shuip/tanstack-form/select-field';
import { SubmitButton } from '@/components/ui/shuip/tanstack-form/submit-button';
const COUNTRIES = {
'United States': 'us',
Canada: 'ca',
Mexico: 'mx',
};
const STATES_BY_COUNTRY: Record<string, Record<string, string>> = {
us: {
California: 'ca',
Texas: 'tx',
'New York': 'ny',
Florida: 'fl',
},
ca: {
Ontario: 'on',
Quebec: 'qc',
'British Columbia': 'bc',
Alberta: 'ab',
},
mx: {
'Mexico City': 'cdmx',
Jalisco: 'jal',
'Nuevo León': 'nl',
},
};
export default function TsfSelectFieldDependentExample() {
const [stateOptions, setStateOptions] = React.useState<Record<string, string>>({});
const form = useForm({
defaultValues: {
country: '',
state: '',
},
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'
>
<SelectField
form={form}
name='country'
options={COUNTRIES}
label='Country'
placeholder='Select a country'
formProps={{
validators: {
onChange: ({ value }) => (!value ? 'Please select a country' : undefined),
},
listeners: {
onChange: ({ value }) => {
if (value && STATES_BY_COUNTRY[value]) {
setStateOptions(STATES_BY_COUNTRY[value]);
} else {
setStateOptions({});
}
// Reset state field when country changes
form.setFieldValue('state', '');
},
},
}}
/>
<SelectField
form={form}
name='state'
options={stateOptions}
label='State / Province'
placeholder={Object.keys(stateOptions).length === 0 ? 'Select a country first' : 'Select a state'}
formProps={{
validators: {
onChange: ({ value }) => (!value ? 'Please select a state' : undefined),
},
}}
/>
<SubmitButton form={form}>Submit</SubmitButton>
</form>
);
}

Dynamic

Loading categories...
'use client';
import { useForm } from '@tanstack/react-form';
import React from 'react';
import { SelectField } from '@/components/ui/shuip/tanstack-form/select-field';
import { SubmitButton } from '@/components/ui/shuip/tanstack-form/submit-button';
// Simulate API call
async function fetchCategories(): Promise<Record<string, string>> {
await new Promise((resolve) => setTimeout(resolve, 1000));
return {
Technology: 'tech',
Design: 'design',
Marketing: 'marketing',
Finance: 'finance',
Health: 'health',
};
}
export default function TsfSelectFieldDynamicExample() {
const [categories, setCategories] = React.useState<Record<string, string>>({});
const [isLoading, setIsLoading] = React.useState(true);
const form = useForm({
defaultValues: {
category: '',
},
onSubmit: async ({ value }) => {
await new Promise((resolve) => setTimeout(resolve, 1000));
alert(JSON.stringify(value, null, 2));
},
});
React.useEffect(() => {
fetchCategories()
.then((data) => {
setCategories(data);
setIsLoading(false);
})
.catch(() => {
setIsLoading(false);
});
}, []);
if (isLoading) {
return <div className='text-muted-foreground'>Loading categories...</div>;
}
return (
<form
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
className='space-y-4'
>
<SelectField
form={form}
name='category'
options={categories}
label='Category'
placeholder='Select a category'
description='Categories loaded from API'
formProps={{
validators: {
onChange: ({ value }) => (!value ? 'Please select a category' : undefined),
},
}}
/>
<SubmitButton form={form}>Submit</SubmitButton>
</form>
);
}

Default

Loading...
'use client';
import { useForm } from '@tanstack/react-form';
import { SelectField } from '@/components/ui/shuip/tanstack-form/select-field';
import { SubmitButton } from '@/components/ui/shuip/tanstack-form/submit-button';
export default function TsfSelectFieldExample() {
const form = useForm({
defaultValues: {
country: '',
},
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'
>
<SelectField
form={form}
name='country'
options={{
'United States': 'us',
'United Kingdom': 'uk',
France: 'fr',
Germany: 'de',
}}
label='Country'
placeholder='Select a country'
description='Choose your country'
formProps={{
validators: {
onChange: ({ value }) => (!value ? 'Please select a country' : undefined),
},
}}
/>
<SubmitButton form={form} />
</form>
);
}

Props

Prop

Type

On this page