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.

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, 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: addressSchema requires 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: 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 { 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 fullAddress message, "Address is required".
  • Editing the text after a pick clears street, city, postalCode, country and placeId, 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 placeId stay 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 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 { 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

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

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

On this page