Autocomplete Field
Free-text input that suggests matching values from a static list or an async search, integrated with TanStack Form via React context. The committed value is a plain string.
npx shadcn@latest add https://shuip.plvo.dev/r/tsf-autocomplete-field.json
pnpm dlx shadcn@latest add https://shuip.plvo.dev/r/tsf-autocomplete-field.json
bun x shadcn@latest add https://shuip.plvo.dev/r/tsf-autocomplete-field.json
'use client';import { Loader2 } from 'lucide-react';import * as React from 'react';import { Command, CommandEmpty, CommandGroup, CommandItem, CommandList } from '@/components/ui/command';import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field';import { Input } from '@/components/ui/input';import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover';import { useFieldContext } from '@/components/ui/shuip/tanstack-form/form-context';import { cn } from '@/lib/utils';const DEBOUNCE_TIME = 300;function defaultFilter(suggestion: string, query: string) {return suggestion.toLowerCase().includes(query.toLowerCase());}export interface AutocompleteFieldProps {label?: string;description?: string;placeholder?: string;suggestions?: string[];onSearch?: (query: string) => Promise<string[]>;emptyText?: string;debounceMs?: number;filter?: (suggestion: string, query: string) => boolean;fieldProps?: React.ComponentProps<typeof Field>;props?: Omit<React.ComponentProps<'input'>, 'value' | 'onChange'>;}export function AutocompleteField({label,description,placeholder,suggestions,onSearch,emptyText = 'No results',debounceMs = DEBOUNCE_TIME,filter = defaultFilter,fieldProps,props,}: AutocompleteFieldProps) {const field = useFieldContext<string>();const { isValid, errors } = field.state.meta;const [open, setOpen] = React.useState(false);const [selectedIndex, setSelectedIndex] = React.useState(-1);const [results, setResults] = React.useState<string[]>([]);const [isPending, startTransition] = React.useTransition();const debounceTimerRef = React.useRef<NodeJS.Timeout | null>(null);const requestIdRef = React.useRef(0);const popoverRef = React.useRef<HTMLDivElement>(null);const query = field.state.value ?? '';const items = React.useMemo(() => {if (onSearch) return results;if (!suggestions) return [];if (!query) return suggestions;return suggestions.filter((suggestion) => filter(suggestion, query));}, [onSearch, results, suggestions, query, filter]);React.useEffect(() => {if (!onSearch || !open) return;if (debounceTimerRef.current) {clearTimeout(debounceTimerRef.current);}if (!query) {requestIdRef.current++;setResults([]);return;}debounceTimerRef.current = setTimeout(() => {const requestId = ++requestIdRef.current;startTransition(async () => {try {const res = await onSearch(query);if (requestId !== requestIdRef.current) return;setResults(res);} catch {if (requestId !== requestIdRef.current) return;setResults([]);}});}, debounceMs);return () => {if (debounceTimerRef.current) {clearTimeout(debounceTimerRef.current);}requestIdRef.current++;};}, [query, onSearch, open, debounceMs]);const handleSelect = (value: string) => {field.handleChange(value);setOpen(false);setSelectedIndex(-1);};const handleKeyDown = (e: React.KeyboardEvent) => {if (!open) return;if (e.key === 'Escape') {e.preventDefault();setOpen(false);setSelectedIndex(-1);return;}if (items.length === 0) return;switch (e.key) {case 'ArrowDown':e.preventDefault();setSelectedIndex((prev) => (prev < items.length - 1 ? prev + 1 : 0));break;case 'ArrowUp':e.preventDefault();setSelectedIndex((prev) => (prev > 0 ? prev - 1 : items.length - 1));break;case 'Enter':if (selectedIndex >= 0 && selectedIndex < items.length) {e.preventDefault();handleSelect(items[selectedIndex]);}break;}};const handleBlur = (e: React.FocusEvent) => {const relatedTarget = e.relatedTarget as HTMLElement;if (popoverRef.current?.contains(relatedTarget)) {return;}field.handleBlur();setTimeout(() => {setOpen(false);setSelectedIndex(-1);}, 150);};const id = props?.id ?? field.name;return (<Field className='gap-2' data-invalid={!isValid} {...fieldProps}>{label && <FieldLabel htmlFor={id}>{label}</FieldLabel>}<Popover open={open} onOpenChange={setOpen}><PopoverAnchor asChild><div className='relative'><Inputname={field.name}value={query}placeholder={placeholder}autoComplete='off'{...props}id={id}onChange={(e) => {field.handleChange(e.target.value);setOpen(true);setSelectedIndex(-1);}}onFocus={() => setOpen(true)}onBlur={handleBlur}onKeyDown={handleKeyDown}aria-invalid={!isValid}/>{isPending && (<div className='absolute inset-y-0 right-0 flex items-center pr-3'><Loader2 className='size-4 animate-spin text-muted-foreground' /></div>)}</div></PopoverAnchor><PopoverContentref={popoverRef}className='p-0'align='start'onOpenAutoFocus={(e) => e.preventDefault()}style={{ width: 'var(--radix-popover-trigger-width)' }}><Command shouldFilter={false} className='w-full'><CommandList className='max-h-60'><CommandEmpty>{isPending ? 'Searching…' : emptyText}</CommandEmpty><CommandGroup>{items.map((item, index) => (<CommandItemkey={`${item}-${index}`}value={item}onSelect={() => handleSelect(item)}className={cn('cursor-pointer', selectedIndex === index && 'bg-accent')}>{item}</CommandItem>))}</CommandGroup></CommandList></Command></PopoverContent></Popover>{!isValid && (<FieldErrorclassName='text-xs text-left'errors={errors.map((error) => ({ message: typeof error === 'string' ? error : error?.message }))}/>)}{description && <FieldDescription className='text-xs'>{description}</FieldDescription>}</Field>);}
Loading...
AutocompleteField is a text input that proposes matching strings as the user types, while still allowing any free-text value. It reads the surrounding field via useFieldContext, so you compose it inside a <form.AppField> rather than passing a form instance by prop. It stores a plain string.
Built-in features
- Two suggestion modes: a static
suggestionsarray (filtered client-side) or an asynconSearchfunction (debounced fetch). - Free text: the typed value is always kept — closing the dropdown without selecting commits what was typed.
- Keyboard navigation: ArrowUp/ArrowDown to move, Enter to select, Escape to close.
- Custom matching: override the default case-insensitive substring match via
filter(static mode).
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 { AutocompleteField } from '@/components/ui/shuip/tanstack-form/autocomplete-field';
import { SubmitButton } from '@/components/ui/shuip/tanstack-form/submit-button';
export const { useAppForm } = createFormHook({
fieldContext,
formContext,
fieldComponents: { AutocompleteField },
formComponents: { SubmitButton },
});See the form-context item for details.
Examples
Async
Loading...
'use client';import { createFormHook } from '@tanstack/react-form';import { AutocompleteField } from '@/components/ui/shuip/tanstack-form/autocomplete-field';import { fieldContext, formContext } from '@/components/ui/shuip/tanstack-form/form-context';import { SubmitButton } from '@/components/ui/shuip/tanstack-form/submit-button';const FRUITS = ['Apple', 'Apricot', 'Banana', 'Blackberry', 'Blueberry', 'Cherry', 'Mango', 'Melon', 'Orange', 'Peach'];async function searchFruits(query: string): Promise<string[]> {await new Promise((resolve) => setTimeout(resolve, 400));return FRUITS.filter((fruit) => fruit.toLowerCase().includes(query.toLowerCase()));}const { useAppForm } = createFormHook({fieldContext,formContext,fieldComponents: { AutocompleteField },formComponents: { SubmitButton },});export default function TsfAutocompleteFieldAsyncExample() {const form = useAppForm({defaultValues: {fruit: '',},onSubmit: async ({ value }) => {alert(JSON.stringify(value, null, 2));},});return (<formonSubmit={(e) => {e.preventDefault();form.handleSubmit();}}className='space-y-4'><form.AppFieldname='fruit'children={(field) => (<field.AutocompleteFieldlabel='Fruit'description='Async search with simulated latency'placeholder='Search a fruit…'onSearch={searchFruits}/>)}/><form.AppForm><form.SubmitButton>Submit</form.SubmitButton></form.AppForm></form>);}
Default
Loading...
'use client';import { createFormHook } from '@tanstack/react-form';import { AutocompleteField } from '@/components/ui/shuip/tanstack-form/autocomplete-field';import { fieldContext, formContext } from '@/components/ui/shuip/tanstack-form/form-context';import { SubmitButton } from '@/components/ui/shuip/tanstack-form/submit-button';const SOURCES = ['LinkedIn', 'IRL', 'Prospection', 'Referral', 'Website', 'Cold call'];const { useAppForm } = createFormHook({fieldContext,formContext,fieldComponents: { AutocompleteField },formComponents: { SubmitButton },});export default function TsfAutocompleteFieldExample() {const form = useAppForm({defaultValues: {source: '',},onSubmit: async ({ value }) => {await new Promise((resolve) => setTimeout(resolve, 500));alert(JSON.stringify(value, null, 2));},});return (<formonSubmit={(e) => {e.preventDefault();form.handleSubmit();}}className='space-y-4'><form.AppFieldname='source'validators={{onChange: ({ value }) => (value.length === 0 ? 'Source is required' : undefined),}}children={(field) => (<field.AutocompleteFieldlabel='Source'description='Pick a known source or type your own'placeholder='e.g. LinkedIn'suggestions={SOURCES}/>)}/><form.AppForm><form.SubmitButton>Submit</form.SubmitButton></form.AppForm></form>);}
Props
Prop
Type