Select Field

Dropdown select component integrated with TanStack Form via React context. 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
'use client';
import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useFieldContext } from '@/components/ui/shuip/tanstack-form/form-context';
export type SelectFieldOption = Record<string, string>;
export interface SelectFieldProps {
options: SelectFieldOption;
label?: string;
placeholder?: string;
description?: string;
fieldProps?: React.ComponentProps<typeof Field>;
props?: React.ComponentProps<typeof Select>;
}
export function SelectField({ options, label, description, placeholder, fieldProps, props }: SelectFieldProps) {
const field = useFieldContext<string>();
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}
onValueChange={(value) => field.handleChange(value)}
{...props}
>
<SelectTrigger aria-invalid={!isValid}>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{Object.entries(options).map(([optionLabel, value]) => (
<SelectItem key={value} value={value}>
{optionLabel}
</SelectItem>
))}
</SelectContent>
</Select>
{!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...

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.

The component reads the surrounding field via useFieldContext, so you compose it inside a <form.AppField>. 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

  • Context-bound field state via useFieldContext — no prop drilling
  • 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

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 { SelectField } from '@/components/ui/shuip/tanstack-form/select-field';
import { SubmitButton } from '@/components/ui/shuip/tanstack-form/submit-button';

export const { useAppForm } = createFormHook({
  fieldContext,
  formContext,
  fieldComponents: { SelectField },
  formComponents: { SubmitButton },
});

See the form-context item for details.

Options format

import { useAppForm } from '@/lib/form';

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

const form = useAppForm({
  defaultValues: { choice: '' },
  onSubmit: async ({ value }) => {
    await saveData(value);
  },
});

<form.AppField
  name='choice'
  children={(field) => (
    <field.SelectField
      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
  },
});

<form.AppField
  name='country'
  children={(field) => (
    <field.SelectField options={{ 'United States': 'us' }} label='Country' />
  )}
/>

<form.AppField
  name='state'
  children={(field) => (
    <field.SelectField
      options={states}
      label='State'
      placeholder='Select country first'
    />
  )}
/>

Examples

Conditional

Loading...
'use client';
import { createFormHook, useStore } 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 { SelectField } from '@/components/ui/shuip/tanstack-form/select-field';
import { SubmitButton } from '@/components/ui/shuip/tanstack-form/submit-button';
const { useAppForm } = createFormHook({
fieldContext,
formContext,
fieldComponents: { InputField, SelectField },
formComponents: { SubmitButton },
});
export default function TsfSelectFieldConditionalExample() {
const form = useAppForm({
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'
>
<form.AppField
name='accountType'
validators={{
onChange: ({ value }) => (!value ? 'Please select an account type' : undefined),
}}
children={(field) => (
<field.SelectField
options={{
Personal: 'personal',
Business: 'business',
}}
label='Account Type'
placeholder='Select account type'
/>
)}
/>
{/* Show business fields only when Business is selected */}
{accountType === 'business' && (
<>
<form.AppField
name='businessName'
validators={{
onChange: ({ value }) => (!value ? 'Business name is required' : undefined),
}}
children={(field) => <field.InputField label='Business Name' />}
/>
<form.AppField
name='companySize'
validators={{
onChange: ({ value }) => (!value ? 'Please select company size' : undefined),
}}
children={(field) => (
<field.SelectField
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'
/>
)}
/>
</>
)}
<form.AppForm>
<form.SubmitButton>Create Account</form.SubmitButton>
</form.AppForm>
</form>
);
}

Dependent

Loading...
'use client';
import { createFormHook } from '@tanstack/react-form';
import React from 'react';
import { fieldContext, formContext } from '@/components/ui/shuip/tanstack-form/form-context';
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',
},
};
const { useAppForm } = createFormHook({
fieldContext,
formContext,
fieldComponents: { SelectField },
formComponents: { SubmitButton },
});
export default function TsfSelectFieldDependentExample() {
const [stateOptions, setStateOptions] = React.useState<Record<string, string>>({});
const form = useAppForm({
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'
>
<form.AppField
name='country'
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', '');
},
}}
children={(field) => <field.SelectField options={COUNTRIES} label='Country' placeholder='Select a country' />}
/>
<form.AppField
name='state'
validators={{
onChange: ({ value }) => (!value ? 'Please select a state' : undefined),
}}
children={(field) => (
<field.SelectField
options={stateOptions}
label='State / Province'
placeholder={Object.keys(stateOptions).length === 0 ? 'Select a country first' : 'Select a state'}
/>
)}
/>
<form.AppForm>
<form.SubmitButton>Submit</form.SubmitButton>
</form.AppForm>
</form>
);
}

Dynamic

Loading categories...
'use client';
import { createFormHook } from '@tanstack/react-form';
import React from 'react';
import { fieldContext, formContext } from '@/components/ui/shuip/tanstack-form/form-context';
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',
};
}
const { useAppForm } = createFormHook({
fieldContext,
formContext,
fieldComponents: { SelectField },
formComponents: { SubmitButton },
});
export default function TsfSelectFieldDynamicExample() {
const [categories, setCategories] = React.useState<Record<string, string>>({});
const [isLoading, setIsLoading] = React.useState(true);
const form = useAppForm({
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'
>
<form.AppField
name='category'
validators={{
onChange: ({ value }) => (!value ? 'Please select a category' : undefined),
}}
children={(field) => (
<field.SelectField
options={categories}
label='Category'
placeholder='Select a category'
description='Categories loaded from API'
/>
)}
/>
<form.AppForm>
<form.SubmitButton>Submit</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 { SelectField } from '@/components/ui/shuip/tanstack-form/select-field';
import { SubmitButton } from '@/components/ui/shuip/tanstack-form/submit-button';
const { useAppForm } = createFormHook({
fieldContext,
formContext,
fieldComponents: { SelectField },
formComponents: { SubmitButton },
});
export default function TsfSelectFieldExample() {
const form = useAppForm({
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'
>
<form.AppField
name='country'
validators={{
onChange: ({ value }) => (!value ? 'Please select a country' : undefined),
}}
children={(field) => (
<field.SelectField
options={{
'United States': 'us',
'United Kingdom': 'uk',
France: 'fr',
Germany: 'de',
}}
label='Country'
placeholder='Select a country'
description='Choose your country'
/>
)}
/>
<form.AppForm>
<form.SubmitButton />
</form.AppForm>
</form>
);
}

Props

Prop

Type

On this page