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 @shuip/rhf-address-field
pnpm dlx shadcn@latest add @shuip/rhf-address-field
bun x shadcn@latest add @shuip/rhf-address-field
'use client';
// Override of component.radix.tsx. Tab closes the suggestions and an outside press on the anchor does not dismiss
// them, which the Radix file does not do, so the codemod cannot derive them. The override also keeps the popup from
// taking focus back on close (finalFocus) and lets the pointer pass through the icon over the input.
import { Popover as PopoverPrimitive } from '@base-ui/react/popover';
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 { Field, FieldError, FieldLabel } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { Popover } from '@/components/ui/popover';
import { listboxClassName, SuggestionOption, useSuggestionList } from '@/components/ui/shuip/suggestion-list';
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>;
const ADDRESS_NOT_SELECTED_MESSAGE = 'Select an address from the suggestions';
type AddressErrors = Partial<
Record<'fullAddress' | 'street' | 'city' | 'postalCode' | 'country', { message?: string } | undefined>
>;
export function addressFieldError(errors: AddressErrors): { message?: string } | undefined {
if (errors.fullAddress) return errors.fullAddress;
const unselected = [errors.street, errors.city, errors.postalCode, errors.country].some(Boolean);
return unselected ? { message: ADDRESS_NOT_SELECTED_MESSAGE } : undefined;
}
interface ControlledField {
field: { value?: string; onChange: (value: string) => void };
}
export function clearFilledFields(controllers: ControlledField[]): void {
for (const { field } of controllers) {
if (field.value) field.onChange('');
}
}
interface ElementTarget {
field: { ref: (node: HTMLInputElement | null) => void };
}
export function handElementToFields(controllers: ElementTarget[], node: HTMLInputElement | null): void {
for (const { field } of controllers) field.ref(node);
}
export async function lookupPlaceDetails<Details>(lookup: () => Promise<Details>): Promise<Details | undefined> {
try {
return await lookup();
} catch (error) {
console.error('Error fetching place details:', error);
return undefined;
}
}
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);
const anchorRef = 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 setInputRef = React.useCallback(
(node: HTMLInputElement | null) => {
inputRef.current = node;
handElementToFields([fullAddress, street, city, postalCode, countryField], node);
},
[fullAddress.field.ref, street.field.ref, city.field.ref, postalCode.field.ref, countryField.field.ref],
);
const clearPlace = () => clearFilledFields([street, city, postalCode, countryField, placeId]);
const handleSelectAddress = (suggestion: AddressSuggestion) => {
setShowSuggestions(false);
setSelectedIndex(-1);
setSearchQuery('');
startTransition(async () => {
const details = await lookupPlaceDetails(() =>
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 ?? suggestion.description).trim());
placeId.field.onChange(suggestion.placeId.trim());
} else {
clearPlace();
fullAddress.field.onChange(suggestion.description.trim());
}
});
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (suggestions.length === 0) return;
if (!showSuggestions) {
if (e.key === 'ArrowDown' && searchQuery.length >= 3) {
e.preventDefault();
setShowSuggestions(true);
}
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 'Tab':
setShowSuggestions(false);
setSelectedIndex(-1);
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 error = addressFieldError({
fullAddress: fullAddress.fieldState.error,
street: street.fieldState.error,
city: city.fieldState.error,
postalCode: postalCode.fieldState.error,
country: countryField.fieldState.error,
});
const invalid = error !== undefined;
const id = props.id ?? fullAddress.field.name;
const errorId = `${id}-error`;
const describedBy = [props['aria-describedby'], invalid ? errorId : undefined].filter(Boolean).join(' ') || undefined;
const suggestionList = useSuggestionList({
inputId: id,
label,
ariaLabel: props['aria-label'],
placeholder,
open: showSuggestions,
count: suggestions.length,
selectedIndex,
busy: isPending,
});
return (
<Field className='gap-2' data-invalid={invalid}>
<FieldLabel id={suggestionList.labelId} htmlFor={id}>
{label}
</FieldLabel>
<div className='relative'>
<Popover
open={showSuggestions}
onOpenChange={(nextOpen, { reason, event }) => {
if (reason === 'outside-press' && anchorRef.current?.contains(event.target as Node)) return;
setShowSuggestions(nextOpen);
}}
>
<div ref={anchorRef} className='relative'>
<Input
ref={setInputRef}
name={fullAddress.field.name}
value={fullAddress.field.value ?? ''}
placeholder={placeholder}
autoComplete='off'
{...props}
id={id}
onChange={(e) => {
const value = e.target.value;
clearPlace();
fullAddress.field.onChange(value);
setSearchQuery(value);
}}
onFocus={handleFocus}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
{...suggestionList.inputProps}
aria-invalid={invalid}
aria-describedby={describedBy}
/>
<div className='pointer-events-none 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>
<PopoverPrimitive.Portal>
<PopoverPrimitive.Positioner anchor={anchorRef} align='start' sideOffset={4} className='isolate z-50'>
<PopoverPrimitive.Popup
ref={popoverRef}
data-slot='popover-content'
{...suggestionList.listboxProps}
initialFocus={false}
finalFocus={false}
className={cn(
'z-50 flex w-72 origin-(--transform-origin) flex-col gap-4 rounded-md bg-popover p-4 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
listboxClassName,
)}
style={{ width: 'var(--anchor-width)' }}
>
{suggestions.map((suggestion, index) => (
<SuggestionOption
key={suggestion.placeId}
id={suggestionList.optionId(index)}
highlighted={selectedIndex === index}
onSelect={() => handleSelectAddress(suggestion)}
onHighlight={() => setSelectedIndex(index)}
className='items-start p-3'
>
<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 group-aria-selected/suggestion:text-accent-foreground'>
{suggestion.secondaryText}
</div>
</div>
</SuggestionOption>
))}
</PopoverPrimitive.Popup>
</PopoverPrimitive.Positioner>
</PopoverPrimitive.Portal>
</Popover>
</div>
{description && <p className='text-sm text-muted-foreground'>{description}</p>}
{invalid && <FieldError id={errorId} className='text-xs text-left' errors={[error]} />}
</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.
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, debounced search
- Combobox semantics: the input is a combobox that controls a listbox of suggestions, with ArrowDown/ArrowUp, Enter and Escape, so screen readers announce the suggestions and the highlighted one
- Suggestion required:
addressSchemarequires a resolved place, so the user must pick a suggestion. Without one, the field shows an error, marks the input invalid and takes focus on submit - 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 { 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 onSubmit={form.handleSubmit(onSubmit)}>
<AddressField lens={lens.focus('address')} country='FR' />
</form>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.
Picking a suggestion
addressSchema requires street, city, postalCode and country, and only picking a suggestion fills them. Typing a complete address is not enough: the user has to pick one of the suggestions.
When they do not, submitting the form is refused and the field says why:
- A typed address that was never picked shows one error below the input, "Select an address from the suggestions", marks the input
aria-invalid="true"and moves focus to it. The four sub-fields have no input of their own, so their errors are folded into this single message. - An empty input shows the
fullAddressmessage, "Address is required". - Editing the text after a pick clears
street,city,postalCode,countryandplaceId, so the form never submits a place that no longer matches what the input shows. The error comes back until the user picks another suggestion. - A suggestion whose place details cannot be fetched, or come back empty, counts as not picked: the input keeps the suggestion text, the sub-fields and
placeIdstay empty and submitting shows the same error.
The same holds when no suggestion can appear, for example when GOOGLE_PLACES_API_KEY is missing or the Places request fails. Configure the key before you ship the field.
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 { 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 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>
);
}
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 { 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 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>
);
}
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 { 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 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>
);
}
Props
Prop
Type