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'>
<Input
ref={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>
<PopoverContent
ref={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) => (
<CommandItem
key={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>
);
}
Loading...

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, and placeId under 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: addressSchema is 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_here

To 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 milliseconds

Setup

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 management

With 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

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 { 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' />
<AddressField
lens={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

Loading...
'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>
<AddressField
lens={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>
<AddressField
lens={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

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

On this page