Address Field
Address input component with Google Places autocomplete integration. Automatically parses and validates complete address data.
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 { Loader2, MapPin } from 'lucide-react';import * as React from 'react';import { type FieldPath, type FieldValues, type UseFormRegisterReturn, useFormContext } 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 { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';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[];}interface AddressFieldProps extends React.ComponentProps<typeof Input> {register: UseFormRegisterReturn<FieldPath<FieldValues>>;label?: string;placeholder?: string;description?: string;country?: string;}export function AddressField({register,label = 'Address',placeholder = 'Enter your address',description,country = DEFAULT_COUNTRY,...props}: AddressFieldProps) {const [inputValue, setInputValue] = React.useState('');const [suggestions, setSuggestions] = React.useState<AddressSuggestion[]>([]);const [loading, setLoading] = React.useState(false);const [showSuggestions, setShowSuggestions] = React.useState(false);const [selectedIndex, setSelectedIndex] = React.useState(-1);const debounceTimerRef = React.useRef<NodeJS.Timeout | null>(null);const inputRef = React.useRef<HTMLInputElement>(null);const popoverRef = React.useRef<HTMLDivElement>(null);const form = useFormContext();const searchAddresses = async (query: string) => {if (!query || query.length < 3) {setSuggestions([]);setShowSuggestions(false);return;}setLoading(true);try {const result = await getPlacesAutocomplete({input: query,components: country ? `country:${country}` : undefined,types: 'address',language: LANGUAGE_RESULT,});if (result.error) {throw new Error(result.error);}setSuggestions(result.predictions || []);setShowSuggestions(result.predictions?.length > 0);setSelectedIndex(-1);} catch (error) {console.error('Error searching addresses:', error);setSuggestions([]);setShowSuggestions(false);} finally {setLoading(false);}};React.useEffect(() => {if (debounceTimerRef.current) {clearTimeout(debounceTimerRef.current);}if (inputValue.length >= 3) {debounceTimerRef.current = setTimeout(() => {searchAddresses(inputValue);}, DEBOUNCE_TIME);} else {setSuggestions([]);setShowSuggestions(false);}return () => {if (debounceTimerRef.current) {clearTimeout(debounceTimerRef.current);}};}, [inputValue]);const handleSelectAddress = async (suggestion: AddressSuggestion) => {setInputValue(suggestion.description);setShowSuggestions(false);setSelectedIndex(-1);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 street = '';let city = '';let postalCode = '';let country = '';addressComponents.forEach((component: any) => {const types = component.types;if (types.includes('street_number')) {street = `${component.long_name} ${street}`;}if (types.includes('route')) {street = `${street} ${component.long_name}`;}if (types.includes('locality') || types.includes('administrative_area_level_2')) {city = component.long_name;}if (types.includes('postal_code')) {postalCode = component.long_name;}if (types.includes('country')) {country = component.long_name;}});form.setValue(`${register.name}.street`, street.trim());form.setValue(`${register.name}.city`, city.trim());form.setValue(`${register.name}.postalCode`, postalCode.trim());form.setValue(`${register.name}.country`, country.trim());form.setValue(`${register.name}.fullAddress`, details.result.formatted_address.trim());form.setValue(`${register.name}.placeId`, suggestion.placeId.trim());} else {form.setValue(`${register.name}.fullAddress`, suggestion.description.trim());form.setValue(`${register.name}.placeId`, 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 && inputValue.length >= 3) {setShowSuggestions(true);}};const handleBlur = (e: React.FocusEvent) => {const relatedTarget = e.relatedTarget as HTMLElement;if (popoverRef.current?.contains(relatedTarget)) {return;}setTimeout(() => {setShowSuggestions(false);setSelectedIndex(-1);}, 150);};return (<FormField{...register}name={`${register.name}.fullAddress`}render={({ field }) => (<FormItem><FormLabel className='flex items-center justify-between'>{label}<FormMessage className='max-sm:hidden text-xs opacity-80' /></FormLabel><div className='relative'><Popover open={showSuggestions} onOpenChange={setShowSuggestions}><PopoverTrigger asChild><FormControl><div className='relative'><Inputref={inputRef}value={inputValue}placeholder={placeholder}onChange={(e) => {const value = e.target.value;setInputValue(value);field.onChange(value);}}onFocus={handleFocus}onBlur={handleBlur}onKeyDown={handleKeyDown}autoComplete='off'{...props}/><div className='absolute inset-y-0 right-0 flex items-center pr-3'>{loading ? (<Loader2 className='size-4 animate-spin text-muted-foreground' />) : (<MapPin className='size-4 text-muted-foreground' />)}</div></div></FormControl></PopoverTrigger><PopoverContentref={popoverRef}className='p-0'align='start'onOpenAutoFocus={(e) => e.preventDefault()}style={{ width: inputRef.current?.offsetWidth }}><Command className='w-full'><CommandList className='max-h-60'><CommandEmpty>{loading ? '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>}<FormMessage className='sm:hidden text-xs text-left opacity-80' /></FormItem>)}/>);}
AddressField is an address input component that integrates Google Places API autocomplete with React Hook Form. It provides a single input that automatically parses complete address information including street, city, postal code, and country data.
The component reduces form complexity by replacing multiple address fields with a single autocomplete input. Users can quickly find and select their address from Google's database, reducing errors and improving form completion rates.
Built-in features
- Google Places autocomplete: Fast, accurate address suggestions powered by Google's API
- Automatic address parsing: Extracts street, city, postal code, country, and place ID
- Debounced search: Optimized API calls with configurable delay
- Popover suggestions: Clean dropdown interface for address selection
- Form integration: Native React Hook Form and Zod validation support
- Customizable regions: Configurable country and language settings
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 or language, 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`} language='ts' />
## Less boilerplate
Traditional address forms require multiple fields and manual validation:
```tsx
<Input name="street" placeholder="Street" />
<Input name="city" placeholder="City" />
<Input name="postalCode" placeholder="Postal Code" />
<Input name="country" placeholder="Country" />
// ...manual validation and state managementWith AddressField, this reduces to a single component with automatic validation:
<AddressField
register={form.register('address')}
placeholder="Enter your address"
/>Usage patterns
Basic address field with schema validation:
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { AddressField, addressSchema } from '@/components/ui/shuip/address-field';
const schema = z.object({
address: addressSchema,
});
const form = useForm({
defaultValues: {
address: {
street: '',
city: '',
postalCode: '',
country: '',
fullAddress: '',
placeId: '',
},
},
resolver: zodResolver(schema),
});
<AddressField
register={form.register('address')}
placeholder="Enter your address"
/>Custom configuration for different regions:
<AddressField
register={form.register('address')}
label="Shipping Address"
description="We only ship to valid addresses"
country="FR"
placeholder="Entrez votre adresse"
/>The component returns structured address data:
{
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 { 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,});export default function RhfAddressFieldExample() {const form = useForm({defaultValues: {name: '',address: {street: '',city: '',postalCode: '',country: '',fullAddress: '',placeId: '',},},resolver: zodResolver(zodSchema),});async function onSubmit(values: z.infer<typeof zodSchema>) {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 register={form.register('name')} label='Name' placeholder='Enter your name' /><AddressFieldregister={form.register('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 { 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 { CheckboxField } from '@/components/ui/shuip/react-hook-form/checkbox-field';import { SubmitButton } from '@/components/ui/shuip/submit-button';const zodSchema = z.object({billingAddress: addressSchema,sameAsShipping: z.boolean(),shippingAddress: addressSchema.optional(),});export default function RhfAddressFieldShippingBillingExample() {const form = useForm({defaultValues: {billingAddress: {street: '',city: '',postalCode: '',country: '',fullAddress: '',placeId: '',},sameAsShipping: false,shippingAddress: {street: '',city: '',postalCode: '',country: '',fullAddress: '',placeId: '',},},resolver: zodResolver(zodSchema),});const sameAsShipping = form.watch('sameAsShipping');async function onSubmit(values: z.infer<typeof zodSchema>) {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><AddressFieldregister={form.register('billingAddress')}label='Billing Address'placeholder='Enter billing address'description='Address for payment processing'/></div><CheckboxFieldregister={form.register('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><AddressFieldregister={form.register('shippingAddress')}label='Shipping Address'placeholder='Enter shipping address'description='Address where items will be delivered'/></div>)}<SubmitButton>Save Addresses</SubmitButton></form></Form>);}
Props
Prop
Type