Select Field
Select dropdown component integrated with React Hook Form via typed lens binding from @hookform/lenses. Renders options from a key-value map with controlled value handling.
npx shadcn@latest add @shuip/rhf-select-field
pnpm dlx shadcn@latest add @shuip/rhf-select-field
bun x shadcn@latest add @shuip/rhf-select-field
'use client';
import type { Lens } from '@hookform/lenses';
import type { ComponentProps } from 'react';
import { useController } from 'react-hook-form';
import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
export type SelectFieldOption = Record<string, string>;
export interface SelectFieldProps
extends Omit<ComponentProps<typeof Select>, 'value' | 'defaultValue' | 'onValueChange'> {
lens: Lens<string>;
options: SelectFieldOption;
label?: string;
placeholder?: string;
description?: string;
}
export function SelectField({ lens, options, label, description, placeholder, ...props }: SelectFieldProps) {
const { field, fieldState } = useController(lens.interop());
const selectedLabel =
Object.entries(options).find(([, value]) => value === field.value)?.[0] ?? (field.value || undefined);
return (
<Field className='gap-2' data-invalid={fieldState.invalid}>
{label && <FieldLabel htmlFor={field.name}>{label}</FieldLabel>}
<Select
name={field.name}
value={field.value ?? ''}
// Spreading props into the generic Base UI Select makes it infer an unknown value, so narrow it here.
onValueChange={(next) => field.onChange(typeof next === 'string' ? next : '')}
{...props}
>
<SelectTrigger id={field.name} aria-invalid={fieldState.invalid} className='w-full'>
<SelectValue placeholder={placeholder}>{selectedLabel}</SelectValue>
</SelectTrigger>
<SelectContent>
{Object.entries(options).map(([optionLabel, value]) => (
<SelectItem key={optionLabel} value={value}>
{optionLabel}
</SelectItem>
))}
</SelectContent>
</Select>
{fieldState.invalid && <FieldError className='text-xs text-left' errors={[fieldState.error]} />}
{description && <FieldDescription className='text-xs'>{description}</FieldDescription>}
</Field>
);
}
The command is the same for Base UI and Radix: the CLI reads the style in your components.json and installs the matching variant.
SelectField is a single-choice dropdown component that encapsulates React Hook Form's field management with shadcn/ui's design system. It handles wiring the controlled value, change handler, validation state, and option rendering from a key-value map.
The field binds to the form via a typed lens from @hookform/lenses — no call-site generic, just lens.focus('fieldName') with full autocomplete from your form's value type.
Built-in features
- Typed lens binding:
lens.focus('country')autocompletes from your form's value type — no<MyForm>generic at the call site - Automatic option rendering: pass a
Record<string, string>— keys become labels, values become form data - Controlled value: bound through
value/onValueChange, kept in sync with form state - Zod validation: native integration with react-hook-form and Zod via resolver
- Accessibility: full keyboard navigation and screen reader support via Radix Select
Setup
Field components bind via @hookform/lenses. Create a lens once per form:
import { useLens } from '@hookform/lenses';
import { useForm } from 'react-hook-form';
import { SelectField } from '@/components/ui/shuip/react-hook-form/select-field';
const form = useForm<MyForm>({ defaultValues: { country: 'us' } });
const lens = useLens({ control: form.control });
<form onSubmit={form.handleSubmit(onSubmit)}>
<SelectField
lens={lens.focus('country')}
label='Country'
options={{ 'United States': 'us', Canada: 'ca', France: 'fr' }}
/>
</form>Default selections belong on useForm({ defaultValues }), not on the field component.
Less boilerplate
React Hook Form's standard approach uses render props to access field state:
<FormField
control={form.control}
name="country"
render={({ field }) => (
<FormItem>
<FormLabel>Country</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a country" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="us">United States</SelectItem>
<SelectItem value="ca">Canada</SelectItem>
<SelectItem value="fr">France</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Choose your country
</FormDescription>
<FormMessage />
</FormItem>
)}
/>With SelectField, this reduces to a single declarative component:
<SelectField
lens={lens.focus('country')}
label='Country'
placeholder='Select a country'
description='Choose your country'
options={{ 'United States': 'us', Canada: 'ca', France: 'fr' }}
/>Examples
Conditional
'use client';
import { useLens } from '@hookform/lenses';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { InputField } from '@/components/ui/shuip/react-hook-form/input-field';
import { SelectField } from '@/components/ui/shuip/react-hook-form/select-field';
import { SubmitButton } from '@/components/ui/shuip/submit-button';
const zodSchema = z.object({
accountType: z.enum(['personal', 'business']),
businessName: z.string(),
companySize: z.string(),
});
type FormValues = { accountType: string; businessName: string; companySize: string };
type SubmitValues = z.infer<typeof zodSchema>;
export default function RhfSelectFieldConditionalExample() {
const form = useForm<FormValues, unknown, SubmitValues>({
defaultValues: {
accountType: '',
businessName: '',
companySize: '',
},
resolver: zodResolver<FormValues, unknown, SubmitValues>(zodSchema),
});
const lens = useLens({ control: form.control });
const accountType = form.watch('accountType');
async function onSubmit(values: SubmitValues) {
try {
alert(JSON.stringify(values, null, 2));
} catch (error) {
console.error(error);
}
}
return (
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'>
<SelectField
lens={lens.focus('accountType')}
options={{
Personal: 'personal',
Business: 'business',
}}
label='Account Type'
placeholder='Select account type'
description='Choose the type of account you want to create'
/>
{/* Show business fields only when Business is selected */}
{accountType === 'business' && (
<>
<InputField
lens={lens.focus('businessName')}
label='Business Name'
placeholder='Enter your business name'
description='Legal name of your business'
/>
<SelectField
lens={lens.focus('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'
description='Number of employees in your company'
/>
</>
)}
<SubmitButton>Create Account</SubmitButton>
</form>
);
}
Dependent
'use client';
import { useLens } from '@hookform/lenses';
import { zodResolver } from '@hookform/resolvers/zod';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { SelectField } from '@/components/ui/shuip/react-hook-form/select-field';
import { SubmitButton } from '@/components/ui/shuip/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 zodSchema = z.object({
country: z.string().min(1, 'Please select a country'),
state: z.string().min(1, 'Please select a state'),
});
type Values = z.infer<typeof zodSchema>;
export default function RhfSelectFieldDependentExample() {
const [stateOptions, setStateOptions] = useState<Record<string, string>>({});
const form = useForm<Values>({
defaultValues: {
country: '',
state: '',
},
resolver: zodResolver(zodSchema),
});
const lens = useLens({ control: form.control });
const country = form.watch('country');
useEffect(() => {
if (country && STATES_BY_COUNTRY[country]) {
setStateOptions(STATES_BY_COUNTRY[country]);
} else {
setStateOptions({});
}
// Reset state field when country changes
form.setValue('state', '');
}, [country, form]);
async function onSubmit(values: Values) {
try {
alert(JSON.stringify(values, null, 2));
} catch (error) {
console.error(error);
}
}
return (
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'>
<SelectField
lens={lens.focus('country')}
options={COUNTRIES}
label='Country'
placeholder='Select a country'
description='Choose your country'
/>
<SelectField
lens={lens.focus('state')}
options={stateOptions}
label='State / Province'
placeholder={Object.keys(stateOptions).length === 0 ? 'Select a country first' : 'Select a state'}
description='Choose your state or province'
/>
<SubmitButton>Submit</SubmitButton>
</form>
);
}
Default
'use client';
import { useLens } from '@hookform/lenses';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { SelectField, type SelectFieldOption } from '@/components/ui/shuip/react-hook-form/select-field';
import { SubmitButton } from '@/components/ui/shuip/submit-button';
const options: SelectFieldOption = {
First: '1',
Second: '2',
Third: '3',
Fourth: '4',
};
const zodSchema = z.object({
selection: z.enum(Object.values(options) as [string]),
});
type Values = z.infer<typeof zodSchema>;
export default function SelectFieldExample() {
const form = useForm<Values>({
defaultValues: { selection: '3' },
resolver: zodResolver(zodSchema),
});
const lens = useLens({ control: form.control });
async function onSubmit(values: Values) {
try {
alert(`Selection: ${values.selection}`);
} catch (error) {
console.error(error);
}
}
return (
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'>
<SelectField lens={lens.focus('selection')} placeholder='Select an option' label='selection' options={options} />
<SubmitButton>Check</SubmitButton>
</form>
);
}
Props
Prop
Type