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.

Loading...

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

Loading...
'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

Loading...
'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

Loading...
'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

On this page