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'>
<Input
ref={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>
<PopoverContent
ref={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) => (
<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>}
<FormMessage className='sm:hidden text-xs text-left opacity-80' />
</FormItem>
)}
/>
);
}
Loading...

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 management

With 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

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

Loading...
'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>
<AddressField
register={form.register('billingAddress')}
label='Billing Address'
placeholder='Enter billing address'
description='Address for payment processing'
/>
</div>
<CheckboxField
register={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>
<AddressField
register={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

On this page