Address Field
Compound address input with Google Places autocomplete, integrated with React Hook Form via typed lens binding from @hookform/lenses.
npx shadcn@latest add https://shuip.plvo.dev/r/rhf-address-field.json
pnpm dlx shadcn@latest add https://shuip.plvo.dev/r/rhf-address-field.json
bun x shadcn@latest add https://shuip.plvo.dev/r/rhf-address-field.json
'use client';import type { Lens } from '@hookform/lenses';import { Loader2, MapPin } from 'lucide-react';import * as React from 'react';import { useController } from 'react-hook-form';import { z } from 'zod';import { getPlaceDetails, getPlacesAutocomplete } from '@/actions/shuip/places';import { Command, CommandEmpty, CommandGroup, CommandItem, CommandList } from '@/components/ui/command';import { Field, FieldError, FieldLabel } from '@/components/ui/field';import { Input } from '@/components/ui/input';import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';import { cn } from '@/lib/utils';const DEFAULT_COUNTRY = 'US';const LANGUAGE_RESULT = 'en';const DEBOUNCE_TIME = 300;export const addressSchema = z.object({street: z.string().min(1, 'Street is required'),city: z.string().min(1, 'City is required'),postalCode: z.string().min(1, 'Postal code is required'),country: z.string().min(1, 'Country is required'),fullAddress: z.string().min(1, 'Address is required'),placeId: z.string().optional(),});export type AddressData = z.infer<typeof addressSchema>;interface AddressSuggestion {placeId: string;description: string;mainText: string;secondaryText: string;types: string[];}export interface AddressFieldProps extends Omit<React.ComponentProps<typeof Input>, 'value' | 'onChange'> {lens: Lens<AddressData>;label?: string;placeholder?: string;description?: string;country?: string;}export function AddressField({lens,label = 'Address',placeholder = 'Enter your address',description,country = DEFAULT_COUNTRY,...props}: AddressFieldProps) {const fullAddress = useController(lens.focus('fullAddress').interop());const street = useController(lens.focus('street').interop());const city = useController(lens.focus('city').interop());const postalCode = useController(lens.focus('postalCode').interop());const countryField = useController(lens.focus('country').interop());const placeId = useController(lens.focus('placeId').interop());const [searchQuery, setSearchQuery] = React.useState('');const [suggestions, setSuggestions] = React.useState<AddressSuggestion[]>([]);const [showSuggestions, setShowSuggestions] = React.useState(false);const [selectedIndex, setSelectedIndex] = React.useState(-1);const [isPending, startTransition] = React.useTransition();const debounceTimerRef = React.useRef<NodeJS.Timeout | null>(null);const requestIdRef = React.useRef(0);const inputRef = React.useRef<HTMLInputElement>(null);const popoverRef = React.useRef<HTMLDivElement>(null);React.useEffect(() => {if (debounceTimerRef.current) {clearTimeout(debounceTimerRef.current);}if (searchQuery.length < 3) {setSuggestions([]);setShowSuggestions(false);return;}debounceTimerRef.current = setTimeout(() => {const requestId = ++requestIdRef.current;startTransition(async () => {try {const result = await getPlacesAutocomplete({input: searchQuery,components: country ? `country:${country}` : undefined,types: 'address',language: LANGUAGE_RESULT,});if (requestId !== requestIdRef.current) return;if (result.error) {throw new Error(result.error);}setSuggestions(result.predictions || []);setShowSuggestions(result.predictions?.length > 0);setSelectedIndex(-1);} catch (error) {if (requestId !== requestIdRef.current) return;console.error('Error searching addresses:', error);setSuggestions([]);setShowSuggestions(false);}});}, DEBOUNCE_TIME);return () => {if (debounceTimerRef.current) {clearTimeout(debounceTimerRef.current);}requestIdRef.current++;};}, [searchQuery, country]);const handleSelectAddress = (suggestion: AddressSuggestion) => {setShowSuggestions(false);setSelectedIndex(-1);setSearchQuery('');startTransition(async () => {const details = await getPlaceDetails({placeId: suggestion.placeId,fields: ['address_components', 'formatted_address', 'geometry'],language: LANGUAGE_RESULT,});if (details?.result) {const addressComponents = details.result.address_components || [];let streetValue = '';let cityValue = '';let postalCodeValue = '';let countryValue = '';addressComponents.forEach((component: any) => {const types = component.types;if (types.includes('street_number')) {streetValue = `${component.long_name} ${streetValue}`;}if (types.includes('route')) {streetValue = `${streetValue} ${component.long_name}`;}if (types.includes('locality') || types.includes('administrative_area_level_2')) {cityValue = component.long_name;}if (types.includes('postal_code')) {postalCodeValue = component.long_name;}if (types.includes('country')) {countryValue = component.long_name;}});street.field.onChange(streetValue.trim());city.field.onChange(cityValue.trim());postalCode.field.onChange(postalCodeValue.trim());countryField.field.onChange(countryValue.trim());fullAddress.field.onChange(details.result.formatted_address.trim());placeId.field.onChange(suggestion.placeId.trim());} else {fullAddress.field.onChange(suggestion.description.trim());placeId.field.onChange(suggestion.placeId.trim());}});};const handleKeyDown = (e: React.KeyboardEvent) => {if (!showSuggestions || suggestions.length === 0) return;switch (e.key) {case 'ArrowDown':e.preventDefault();setSelectedIndex((prev) => (prev < suggestions.length - 1 ? prev + 1 : 0));break;case 'ArrowUp':e.preventDefault();setSelectedIndex((prev) => (prev > 0 ? prev - 1 : suggestions.length - 1));break;case 'Enter':e.preventDefault();if (selectedIndex >= 0 && selectedIndex < suggestions.length) {handleSelectAddress(suggestions[selectedIndex]);}break;case 'Escape':e.preventDefault();setShowSuggestions(false);setSelectedIndex(-1);inputRef.current?.blur();break;}};const handleFocus = () => {if (suggestions.length > 0 && searchQuery.length >= 3) {setShowSuggestions(true);}};const handleBlur = (e: React.FocusEvent) => {const relatedTarget = e.relatedTarget as HTMLElement;if (popoverRef.current?.contains(relatedTarget)) {return;}fullAddress.field.onBlur();setTimeout(() => {setShowSuggestions(false);setSelectedIndex(-1);}, 150);};const fullAddressInvalid = fullAddress.fieldState.invalid;const id = props.id ?? fullAddress.field.name;return (<Field className='gap-2' data-invalid={fullAddressInvalid}><FieldLabel htmlFor={id} className='flex items-center justify-between'>{label}{fullAddressInvalid && (<FieldError className='max-sm:hidden text-xs opacity-80' errors={[fullAddress.fieldState.error]} />)}</FieldLabel><div className='relative'><Popover open={showSuggestions} onOpenChange={setShowSuggestions}><PopoverTrigger asChild><div className='relative'><Inputref={inputRef}name={fullAddress.field.name}value={fullAddress.field.value ?? ''}placeholder={placeholder}autoComplete='off'{...props}id={id}onChange={(e) => {const value = e.target.value;fullAddress.field.onChange(value);setSearchQuery(value);}}onFocus={handleFocus}onBlur={handleBlur}onKeyDown={handleKeyDown}aria-invalid={fullAddressInvalid}/><div className='absolute inset-y-0 right-0 flex items-center pr-3'>{isPending ? (<Loader2 className='size-4 animate-spin text-muted-foreground' />) : (<MapPin className='size-4 text-muted-foreground' />)}</div></div></PopoverTrigger><PopoverContentref={popoverRef}className='p-0'align='start'onOpenAutoFocus={(e) => e.preventDefault()}style={{ width: 'var(--radix-popover-trigger-width)' }}><Command className='w-full'><CommandList className='max-h-60'><CommandEmpty>{isPending ? 'Searching...' : 'No addresses found'}</CommandEmpty><CommandGroup>{suggestions.map((suggestion, index) => (<CommandItemkey={suggestion.placeId}value={suggestion.description}onSelect={() => handleSelectAddress(suggestion)}className={cn('flex items-start space-x-2 p-3 cursor-pointer',selectedIndex === index && 'bg-accent',)}><MapPin className='size-4 mt-0.5 text-muted-foreground flex-shrink-0' /><div className='flex-1 min-w-0'><div className='font-medium text-sm'>{suggestion.mainText}</div><div className='text-xs text-muted-foreground truncate'>{suggestion.secondaryText}</div></div></CommandItem>))}</CommandGroup></CommandList></Command></PopoverContent></Popover></div>{description && <p className='text-sm text-muted-foreground'>{description}</p>}{fullAddressInvalid && (<FieldError className='sm:hidden text-xs text-left opacity-80' errors={[fullAddress.fieldState.error]} />)}</Field>);}
AddressField is a compound field component that manages a full AddressData sub-tree (street, city, postal code, country, full address, place ID) under a single name prefix. It wires Google Places autocomplete to the form so that selecting a suggestion writes every sub-field at once.
The field binds to the form via a typed lens from @hookform/lenses. The parent passes lens={lens.focus('address')} — a Lens<AddressData> — and the component focuses internally on each sub-field. No string paths, no useFormContext, no name-prefix hacks.
Built-in features
- Compound field: manages
street,city,postalCode,country,fullAddress, andplaceIdunder a single sub-tree - Typed lens binding:
lens.focus('address')autocompletes from your form's value type — no<MyForm>generic at the call site - Google Places autocomplete: server action, popover dropdown, keyboard navigation, debounced search
- Zod schema export:
addressSchemais published alongside the component for easy composition (z.object({ address: addressSchema }))
Configuration
This component requires a Google Places API key. Add GOOGLE_PLACES_API_KEY to your .env file:
GOOGLE_PLACES_API_KEY=your_api_key_hereTo customize the default country, language, or debounce, edit the constants in the component:
const DEFAULT_COUNTRY = 'US'; // Country code for suggestions
const LANGUAGE_RESULT = 'en'; // Language code for results
const DEBOUNCE_TIME = 300; // API call delay in millisecondsSetup
Field components bind via @hookform/lenses. Create a lens once per form, then focus on the address sub-tree:
import { useLens } from '@hookform/lenses';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Form } from '@/components/ui/form';
import { AddressField, addressSchema } from '@/components/ui/shuip/react-hook-form/address-field';
const schema = z.object({ address: addressSchema });
const form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema),
defaultValues: {
address: { street: '', city: '', postalCode: '', country: '', fullAddress: '' },
},
});
const lens = useLens({ control: form.control });
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<AddressField lens={lens.focus('address')} country='FR' />
</form>
</Form>The <Form> wrapper is required — it provides shadcn's FormProvider which FormLabel and FormMessage use internally.
Compound field
Unlike a single-input field, AddressField writes to every sub-field of the AddressData sub-tree when the user picks a place suggestion. Internally, it derives one controller per sub-field from the lens:
const fullAddress = useController(lens.focus('fullAddress').interop());
const street = useController(lens.focus('street').interop());
const city = useController(lens.focus('city').interop());
const postalCode = useController(lens.focus('postalCode').interop());
const country = useController(lens.focus('country').interop());
const placeId = useController(lens.focus('placeId').interop());When Google Places returns details for a selected suggestion, each field.onChange(...) populates its sub-field — no global setValue call against string paths.
Less boilerplate
Traditional address forms require multiple fields and manual validation:
<Input name='address.street' placeholder='Street' />
<Input name='address.city' placeholder='City' />
<Input name='address.postalCode' placeholder='Postal Code' />
<Input name='address.country' placeholder='Country' />
// ...manual validation and state managementWith AddressField, this reduces to a single component with automatic validation and place lookup:
<AddressField lens={lens.focus('address')} placeholder='Enter your address' />Schema composition
addressSchema is exported alongside the component so consumers can compose it into a larger form schema:
import { addressSchema } from '@/components/ui/shuip/react-hook-form/address-field';
const schema = z.object({
customer: z.string().min(1),
shipping: addressSchema,
billing: addressSchema,
});The component returns structured address data on submit:
{
address: {
street: '123 Main St',
city: 'Paris',
postalCode: '75001',
country: 'France',
fullAddress: '123 Main St, 75001 Paris, France',
placeId: 'abcdef123456',
}
}Examples
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 { AddressField, addressSchema } from '@/components/ui/shuip/react-hook-form/address-field';import { InputField } from '@/components/ui/shuip/react-hook-form/input-field';import { SubmitButton } from '@/components/ui/shuip/submit-button';const zodSchema = z.object({name: z.string().min(1, 'Name is required'),address: addressSchema,});type Values = z.infer<typeof zodSchema>;export default function RhfAddressFieldExample() {const form = useForm<Values>({defaultValues: {name: '',address: {street: '',city: '',postalCode: '',country: '',fullAddress: '',placeId: '',},},resolver: zodResolver(zodSchema),});const lens = useLens({ control: form.control });async function onSubmit(values: Values) {try {alert(JSON.stringify(values, null, 2));form.reset();} catch (error) {console.error(error);}}return (<Form {...form}><form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4 w-full'><InputField lens={lens.focus('name')} label='Name' placeholder='Enter your name' /><AddressFieldlens={lens.focus('address')}label='Address'placeholder='Enter your address'description='Start typing to see address suggestions'className='w-full'/><SubmitButton>Save Address</SubmitButton><pre className='border border-primary rounded-md p-4 overflow-x-auto'><h3 className='text-primary'>Form values</h3><pre>{JSON.stringify(form.watch(), null, 2)}</pre></pre></form></Form>);}
Shipping Billing
'use client';import { useLens } from '@hookform/lenses';import { zodResolver } from '@hookform/resolvers/zod';import * as React from 'react';import { useForm } from 'react-hook-form';import { z } from 'zod';import { Form } from '@/components/ui/form';import { type AddressData, AddressField, addressSchema } from '@/components/ui/shuip/react-hook-form/address-field';import { CheckboxField } from '@/components/ui/shuip/react-hook-form/checkbox-field';import { SubmitButton } from '@/components/ui/shuip/submit-button';const zodSchema = z.object({billing: addressSchema,sameAsShipping: z.boolean(),shipping: addressSchema,});type Values = z.infer<typeof zodSchema>;const emptyAddress = {street: '',city: '',postalCode: '',country: '',fullAddress: '',placeId: '',};export default function RhfAddressFieldShippingBillingExample() {const form = useForm<Values>({defaultValues: {billing: emptyAddress,sameAsShipping: false,shipping: emptyAddress,},resolver: zodResolver(zodSchema),});const lens = useLens({ control: form.control });const sameAsShipping = form.watch('sameAsShipping');React.useEffect(() => {if (!sameAsShipping) return;form.setValue('shipping', form.getValues('billing'), { shouldValidate: true });const sub = form.watch((values, { name }) => {if (name?.startsWith('billing') && values.billing) {form.setValue('shipping', values.billing as AddressData, { shouldValidate: true });}});return () => sub.unsubscribe();}, [sameAsShipping, 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-6 w-full'><div className='space-y-4'><h3 className='text-lg font-semibold'>Billing Address</h3><AddressFieldlens={lens.focus('billing')}label='Billing Address'placeholder='Enter billing address'description='Address for payment processing'/></div><CheckboxField lens={lens.focus('sameAsShipping')} label='Shipping address is the same as billing address' />{!sameAsShipping && (<div className='space-y-4'><h3 className='text-lg font-semibold'>Shipping Address</h3><AddressFieldlens={lens.focus('shipping')}label='Shipping Address'placeholder='Enter shipping address'description='Address where items will be delivered'/></div>)}<SubmitButton>Save Addresses</SubmitButton></form></Form>);}
Standalone
'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 { AddressField, addressSchema } from '@/components/ui/shuip/react-hook-form/address-field';import { InputField } from '@/components/ui/shuip/react-hook-form/input-field';import { SubmitButton } from '@/components/ui/shuip/submit-button';const zodSchema = z.object({name: z.string(),address: addressSchema,});type Values = z.infer<typeof zodSchema>;export default function AddressFieldExample() {const form = useForm<Values>({defaultValues: {name: 'John Doe',address: {street: '',city: '',postalCode: '',country: '',fullAddress: '',placeId: '',},},resolver: zodResolver(zodSchema),});const lens = useLens({ control: form.control });async function onSubmit(values: Values) {try {alert(`Values: ${JSON.stringify(values, null, 2)}`);form.reset();} catch (error) {console.error(error);}}return (<Form {...form}><form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4 w-full'><InputField lens={lens.focus('name')} label='Name' placeholder='Enter your name' /><AddressField lens={lens.focus('address')} placeholder='Enter your address' className='w-full' /><SubmitButton>Check</SubmitButton><pre className='border border-primary rounded-md p-4 overflow-x-auto'><h3 className='text-primary'>Form values</h3><pre>{JSON.stringify(form.watch(), null, 2)}</pre></pre></form></Form>);}
Props
Prop
Type