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 @shuip/tsf-select-field
pnpm dlx shadcn@latest add @shuip/tsf-select-field
bun x shadcn@latest add @shuip/tsf-select-field
'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;
  const selectedLabel =
    Object.entries(options).find(([, value]) => value === field.state.value)?.[0] ?? (field.state.value || undefined);

  return (
    <Field className='gap-2' data-invalid={!isValid} {...fieldProps}>
      {label && <FieldLabel>{label}</FieldLabel>}
      <Select
        name={field.name}
        value={field.state.value}
        // Spreading props into the generic Base UI Select makes it infer an unknown value, so narrow it here.
        onValueChange={(value) => field.handleChange(typeof value === 'string' ? value : '')}
        {...props}
      >
        <SelectTrigger aria-invalid={!isValid}>
          <SelectValue placeholder={placeholder}>{selectedLabel}</SelectValue>
        </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>
  );
}

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

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