Address Form in React: Country Select, Postcode Validation, Autofill
Build a production-ready address form in React with dynamic country select, per-country postcode validation, and browser autofill that actually works.
Why Address Forms Are Harder Than They Look
You'd think an address form would be boring. Four fields, a submit button, done. In practice, it's one of the most friction-heavy parts of any checkout — and also one of the most frequently broken. Get it wrong and you're watching conversion drop at the exact moment someone had their wallet out.
The real complexity lives in the intersection of three things: country-specific field layouts, postcode formats that vary wildly by locale, and browser autofill that fights you every step of the way if your autocomplete attributes are off. Each of these is solvable individually. Solving all three together, without the form collapsing into a pile of conditional rendering spaghetti, is the actual challenge.
Honestly, most teams ship a one-size-fits-all form with a single regex like /^[A-Z0-9]{3,10}$/i and call it a day. That fails Canadian postal codes (A1A 1A1), Japanese zip codes (7 digits), and Irish Eircodes (A65 F4E2) equally. Your users in those markets feel it immediately.
This guide walks through building an address form in React that handles country-aware validation, dynamic field rendering, and autofill correctly. We'll use React Hook Form because it handles uncontrolled inputs cleanly and pairs well with Zod for schema validation. If you want the finished component styled and ready to drop in, browse components on Empire UI.
Setting Up the Country Select
Start with a typed country list. You don't need a full npm package for this — a trimmed JSON of ISO 3166-1 alpha-2 codes and display names is plenty. Keep it under 50KB. The <select> element here needs a specific autocomplete="country" attribute; without it Chrome won't map autofill correctly.
import { useFormContext } from 'react-hook-form';
const COUNTRIES = [
{ code: 'US', name: 'United States' },
{ code: 'GB', name: 'United Kingdom' },
{ code: 'CA', name: 'Canada' },
{ code: 'AU', name: 'Australia' },
{ code: 'FR', name: 'France' },
{ code: 'JP', name: 'Japan' },
{ code: 'IE', name: 'Ireland' },
// ... add your markets
];
export function CountrySelect() {
const { register } = useFormContext();
return (
<select
{...register('country')}
autoComplete="country"
defaultValue="US"
>
{COUNTRIES.map((c) => (
<option key={c.code} value={c.code}>
{c.name}
</option>
))}
</select>
);
}Worth noting: use value={c.code} (the ISO code), not the display name. Your validation logic and postcode regex map runs against the code, not the string "United States". If you store display names you'll be doing a reverse lookup every time the form submits — just store the code.
The defaultValue should come from a geo-detection call, not hardcoded "US". A lightweight approach is reading Intl.DateTimeFormat().resolvedOptions().timeZone and mapping the timezone to a country code. It's not perfect but it's good enough for a default suggestion. Let the user override it — always.
One more thing — when the country changes, you need to reset the postcode field. Don't let a previously valid UK postcode sit in the field after someone switches to Japan. A useEffect watching the country value that calls setValue('postcode', '') is the cleanest solution here.
Per-Country Postcode Validation with Zod
This is the part most tutorials skip. Every country has its own postcode format. The US uses 5 digits (or ZIP+4), Canada uses alternating letter-digit pairs with a space in the middle, UK postcodes have a specific structure like SW1A 1AA, and Ireland's Eircode was only introduced in 2015 — it still confuses form validators built before then.
Build a map of country codes to their regex patterns and human-readable format hints. Then use Zod's .superRefine() to apply country-aware validation at the schema level rather than sprinkling validate callbacks all over your JSX.
import { z } from 'zod';
const POSTCODE_PATTERNS: Record<string, { regex: RegExp; hint: string }> = {
US: { regex: /^\d{5}(-\d{4})?$/, hint: '12345 or 12345-6789' },
GB: { regex: /^[A-Z]{1,2}\d[A-Z\d]? ?\d[A-Z]{2}$/i, hint: 'SW1A 1AA' },
CA: { regex: /^[A-Z]\d[A-Z] ?\d[A-Z]\d$/i, hint: 'K1A 0A9' },
AU: { regex: /^\d{4}$/, hint: '2000' },
FR: { regex: /^\d{5}$/, hint: '75001' },
JP: { regex: /^\d{3}-?\d{4}$/, hint: '100-0001' },
IE: { regex: /^[A-Z0-9]{3} ?[A-Z0-9]{4}$/i, hint: 'A65 F4E2' },
};
const addressSchema = z.object({
country: z.string().length(2),
line1: z.string().min(3).max(100),
line2: z.string().max(100).optional(),
city: z.string().min(2).max(100),
state: z.string().optional(),
postcode: z.string(),
}).superRefine((data, ctx) => {
const pattern = POSTCODE_PATTERNS[data.country];
if (pattern && !pattern.regex.test(data.postcode)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['postcode'],
message: `Invalid postcode format. Expected: ${pattern.hint}`,
});
}
});That superRefine gives you access to the whole object, so the postcode validation can see the currently selected country. Countries not in your map get a pass — they'll submit whatever the user typed. That's intentional. You don't want to block a user from a market you haven't explicitly configured just because you didn't add their country's regex yet.
Quick aside: in countries like Germany, the Netherlands, and Spain, postcodes are purely numeric but the length differs (Germany is 5 digits, Netherlands is 4, Spain is 5). If you're expanding internationally, group them by pattern type rather than repeating yourself. A regex like /^\d{5}$/ covers US, FR, DE, and ES in one line.
Making Browser Autofill Work
Browser autofill is the fastest way to fill an address form, and it's completely opt-in based on your autocomplete attribute values. Get them wrong and Chrome, Safari, and Firefox will either skip the field or fill the wrong one. There's a specific set of values defined in HTML 5.2 — these aren't arbitrary strings.
export function AddressForm() {
const { register, watch, formState: { errors } } = useFormContext();
const country = watch('country');
const showState = ['US', 'CA', 'AU'].includes(country);
return (
<fieldset>
<legend>Shipping Address</legend>
<input
{...register('line1')}
autoComplete="address-line1"
placeholder="Street address"
/>
<input
{...register('line2')}
autoComplete="address-line2"
placeholder="Apartment, suite, etc. (optional)"
/>
<input
{...register('city')}
autoComplete="address-level2"
placeholder="City"
/>
{showState && (
<input
{...register('state')}
autoComplete="address-level1"
placeholder="State / Province"
/>
)}
<input
{...register('postcode')}
autoComplete="postal-code"
placeholder={POSTCODE_PATTERNS[country]?.hint ?? 'Postcode'}
inputMode={country === 'US' || country === 'JP' ? 'numeric' : 'text'}
/>
</fieldset>
);
}Notice inputMode="numeric" on the postcode for US and Japan. On mobile this pulls up the number pad instead of the full keyboard, which is a small UX win — but only where the postcode is actually numeric. Stick inputMode="text" for UK and Canadian postcodes or you'll lock users out of entering letters.
In practice, the <fieldset> and <legend> are not just semantic niceties. Browsers use the fieldset group to scope autofill sections. If you have both a billing address and a shipping address on the same page, wrapping each in its own <fieldset> with a name attribute helps Chrome's autofill heuristics separate them. Without it, the second set of fields often ends up with the wrong data.
Look, don't wrap your inputs in too many custom component layers without forwarding ref and spreading all the native props through. React Hook Form's register() returns a ref, event handlers, and name — all of which need to land on the actual DOM <input>. If you're using a design system component that swallows those props, autofill will break silently. Check with the Empire UI form components which forward refs correctly by default.
Dynamic State/Province Field
The state or province field is another thing that trips teams up. It should only appear for countries where it's a required part of the address — US states, Canadian provinces, Australian states. For most of Europe it's either not used at all or optional. Showing a "State" label to someone in France is confusing at best.
const STATE_COUNTRIES: Record<string, string[]> = {
US: [
'Alabama', 'Alaska', 'Arizona', 'Arkansas', 'California',
'Colorado', 'Connecticut', 'Delaware', 'Florida', 'Georgia',
// ... full list
],
CA: [
'Alberta', 'British Columbia', 'Manitoba', 'New Brunswick',
'Newfoundland and Labrador', 'Nova Scotia', 'Ontario',
'Prince Edward Island', 'Quebec', 'Saskatchewan',
],
AU: [
'New South Wales', 'Victoria', 'Queensland',
'South Australia', 'Western Australia', 'Tasmania',
'Australian Capital Territory', 'Northern Territory',
],
};
export function StateSelect({ country }: { country: string }) {
const { register } = useFormContext();
const options = STATE_COUNTRIES[country];
if (!options) return null;
return (
<select
{...register('state')}
autoComplete="address-level1"
>
<option value="">Select {country === 'CA' ? 'Province' : 'State'}</option>
{options.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
);
}That country === 'CA' ? 'Province' : 'State' distinction is small but it matters. Canadian users notice. And if you're serving Australia, call it "State or Territory" since the NT and ACT are technically territories, not states — pedantic, but accurate.
Worth noting: when the country changes, reset the state field too, not just the postcode. A useEffect watching country that calls setValue('state', '') keeps the form consistent. You don't want "California" sitting in the state field after someone selects Australia.
Wiring It All Together with React Hook Form
The parent form sets up the FormProvider context so child components can call useFormContext() without prop drilling. This is the standard pattern for multi-field complex forms — it keeps each sub-component independent while sharing the same form state.
import { useForm, FormProvider } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
export function CheckoutAddressForm() {
const methods = useForm({
resolver: zodResolver(addressSchema),
defaultValues: {
country: 'US',
line1: '',
line2: '',
city: '',
state: '',
postcode: '',
},
});
const country = methods.watch('country');
// Reset dependent fields on country change
useEffect(() => {
methods.setValue('postcode', '');
methods.setValue('state', '');
methods.clearErrors(['postcode', 'state']);
}, [country]);
const onSubmit = async (data: AddressFormData) => {
// ship it
console.log('Valid address:', data);
};
return (
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(onSubmit)} noValidate>
<CountrySelect />
<input
{...methods.register('line1')}
autoComplete="address-line1"
placeholder="Address line 1"
/>
<input
{...methods.register('line2')}
autoComplete="address-line2"
placeholder="Address line 2 (optional)"
/>
<input
{...methods.register('city')}
autoComplete="address-level2"
placeholder="City"
/>
<StateSelect country={country} />
<input
{...methods.register('postcode')}
autoComplete="postal-code"
placeholder={POSTCODE_PATTERNS[country]?.hint ?? 'Postcode'}
inputMode={['US', 'JP', 'AU', 'FR'].includes(country) ? 'numeric' : 'text'}
/>
{methods.formState.errors.postcode && (
<p role="alert">{methods.formState.errors.postcode.message}</p>
)}
<button type="submit">Continue to Payment</button>
</form>
</FormProvider>
);
}The noValidate attribute on the <form> disables native browser validation UI, letting Zod and React Hook Form take full control. Without it, you get both native popups and your custom error messages fighting for the same space — not a good look.
That role="alert" on the error paragraph is important for screen readers. Without it, ARIA live regions won't announce validation errors to assistive technology. It's a one-attribute fix that significantly improves accessibility. If you want a full deep-dive on accessible form patterns, the react-accessibility-guide has more context on error announcement and focus management.
Honestly, the clearErrors call in the useEffect is easy to forget. Without it, a previous postcode error from the UK fields will linger visually even after the user switches to Australia and enters a valid 4-digit code. The Zod resolver clears it on the next submit, but clearing it on country change gives instant visual feedback that they're starting fresh.
Styling the Form to Match Your Design System
A working address form isn't just functional — it needs to feel like the rest of your UI. If you're building a checkout with any kind of visual polish, floating labels and a consistent input style make a big difference in perceived quality. The same form structure above works whether you apply Tailwind utility classes, CSS modules, or a component library.
For glassmorphism checkouts — which look genuinely good on product pages — you can apply backdrop-filter: blur(12px) to the form card and background: rgba(255, 255, 255, 0.08) to get the frosted glass effect. Check out the glassmorphism form design breakdown for the full styling approach, or grab a ready-made variant from the glassmorphism components page.
.address-form {
background: rgba(255, 255, 255, 0.06);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 16px;
padding: 32px;
}
.address-form input,
.address-form select {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 8px;
padding: 12px 16px;
color: inherit;
width: 100%;
transition: border-color 0.15s;
}
.address-form input:focus,
.address-form select:focus {
outline: none;
border-color: rgba(255, 255, 255, 0.45);
}That 16px blur is a sweet spot — below 8px you barely see the effect, above 24px it starts looking muddy on most backgrounds. The border-radius: 16px on the card and 8px on the inputs gives a consistent visual rhythm without looking nested. And yes, always include -webkit-backdrop-filter as well — Safari as of 2025 still requires the prefixed version.
Quick aside: if you're applying a glassmorphism generator output directly, double-check the generated backdrop-filter value works on your specific background. The effect depends entirely on what's behind the element — a solid #1a1a1a background will make the blur invisible.
FAQ
Build a map of country codes to regex patterns, then use Zod's superRefine() to apply the matching regex based on the selected country. This keeps validation co-located with your schema rather than scattered across components.
Almost always a missing or wrong autocomplete attribute. Use address-line1, address-line2, address-level2 (city), address-level1 (state), postal-code, and country exactly — these are the HTML 5.2 spec values browsers recognize.
Uncontrolled inputs with React Hook Form's register() are the better choice here. They avoid re-rendering the whole form on every keystroke and play nicer with browser autofill, which writes directly to the DOM input value.
Make the postcode field optional in your Zod schema and only apply regex validation for countries in your POSTCODE_PATTERNS map. Countries outside that map submit without postcode validation — don't block users from markets you haven't configured.