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 https://shuip.plvo.dev/r/rhf-select-field.json
pnpm dlx shadcn@latest add https://shuip.plvo.dev/r/rhf-select-field.json
bun x shadcn@latest add https://shuip.plvo.dev/r/rhf-select-field.json
'use client';import type { Lens } from '@hookform/lenses';import type { SelectProps } from '@radix-ui/react-select';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<SelectProps, '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());return (<Field className='gap-2' data-invalid={fieldState.invalid}>{label && <FieldLabel htmlFor={field.name}>{label}</FieldLabel>}<Select name={field.name} value={field.value ?? ''} onValueChange={field.onChange} {...props}><SelectTrigger id={field.name} aria-invalid={fieldState.invalid} className='w-full'><SelectValue placeholder={placeholder} /></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>);}
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 { Form } from '@/components/ui/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 {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<SelectField
lens={lens.focus('country')}
label='Country'
options={{ 'United States': 'us', Canada: 'ca', France: 'fr' }}
/>
</form>
</Form>The <Form> wrapper is required — it provides shadcn's FormProvider which FormLabel and FormMessage use internally. 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 { Form } from '@/components/ui/form';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().optional(),companySize: z.string().optional(),});type Values = z.infer<typeof zodSchema>;export default function RhfSelectFieldConditionalExample() {const form = useForm<Values>({defaultValues: {accountType: undefined,businessName: '',companySize: '',},resolver: zodResolver(zodSchema),});const lens = useLens({ control: form.control });const accountType = form.watch('accountType');async function onSubmit(values: Values) {try {alert(JSON.stringify(values, null, 2));} catch (error) {console.error(error);}}return (<Form {...form}><form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'><SelectFieldlens={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' && (<><InputFieldlens={lens.focus('businessName')}label='Business Name'placeholder='Enter your business name'description='Legal name of your business'/><SelectFieldlens={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></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 { Form } from '@/components/ui/form';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 changesform.setValue('state', '');}, [country, form]);async function onSubmit(values: Values) {try {alert(JSON.stringify(values, null, 2));} catch (error) {console.error(error);}}return (<Form {...form}><form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'><SelectFieldlens={lens.focus('country')}options={COUNTRIES}label='Country'placeholder='Select a country'description='Choose your country'/><SelectFieldlens={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></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 { Form } from '@/components/ui/form';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 {...form}><form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'><SelectFieldlens={lens.focus('selection')}placeholder='Select an option'label='selection'options={options}/><SubmitButton>Check</SubmitButton></form></Form>);}
Props
Prop
Type