Skip to content

AddressForm

Add/edit an address on the evolve-feature-form pattern: a RAC Form whose validationErrors drive display, clean until the first submit then live, and a submit button that is never disabled.

import { AddressForm } from "@evolve-storefront/commerce-ui/address-form";

Renders inside AddressConfigProvider.

Design notes

Country-aware: the country select comes first because it drives both the field layout and the schema (a US address needs a region; a Dutch one is postal-code-first). Configure the country list and the field set through AddressConfigProvider; with no provider the form behaves as it always has.

It performs no write. onValidSubmit receives the validated values and the consumer runs its own mutation — the address book runs useAddressForm from feature-account, checkout runs a cart update. That is what lets one form serve every surface without commerce-ui depending on a feature.

Compound: this root owns the <form> and its state and seeds AddressFormContext, while AddressFormFields and AddressFormSubmit read it from there. That is what lets AddressDialog put cancel and submit in a DialogFooter without this form importing anything dialog-specific.

Preview

AddOpen in Storybook ↗

Usage

A new address starts in the store's country, from AddressConfigProvider's defaultCountry, so the layout and the rules are the right ones before anyone touches the select. preferredCountries puts it at the top of the list; the rest follow by name.

<AddressConfigProvider countries={EU_COUNTRY_CODES} defaultCountry="NL" preferredCountries={["NL"]}>
<AddressForm onValidSubmit={save}>…</AddressForm>
</AddressConfigProvider>;

Props

PropTypeDescription
onValidSubmit(values: { givenName: string; familyName: string; streetName: string; streetNumber: string; postalCode: string; city: string; country: string; phone: string | u…

Runs with the validated values on a valid submit — where the mutation goes.

childrenReactNode

The composed pieces — typically <AddressFormFields /> and a footer with <AddressFormSubmit>. The form owns state and the <form> element; the caller owns the layout, so a dialog keeps its own DialogFooter.

validationBehavior?"aria" | "native"

Default "aria": mark fields via ARIA rather than blocking native submit. The form is then noValidate, so an input's own constraint (type="email") never stops the submit with a browser tooltip that the field's error does not match.

defaultValues?Partial<{ givenName: string; familyName: string; streetName: string; streetNumber: string; postalCode: string; city: string; country: string; phone: string | un…
onValuesChange?((values: Partial<{ givenName: string; familyName: string; streetName: string; streetNumber: string; postalCode: string; city: string; country: string; phone: s…

Every change, typed or filled by a lookup, unvalidated. For a caller that keeps unsaved input when the form unmounts and passes it back as defaultValues.

supplied?Partial<{ givenName: string; familyName: string; streetName: string; streetNumber: string; postalCode: string; city: string; country: string; phone: string | un…

Values the caller already holds, such as a name asked for earlier in a checkout. Their fields are not rendered; the values are validated and submitted as given.

isPending?boolean
error?string | null

Form-level error (e.g. a failed mutation), shown above the actions.

serverErrors?FormValidationErrors

Server-provided field errors, merged into the same display path.

Any other props go to the root element, like on a plain HTML element.

Change the layout

Pass children to AddressForm to arrange its parts yourself. Every part is exported from @evolve-storefront/commerce-ui/address-form.

AddressFormContextThe context AddressForm fills.

No props of its own.

AddressFormFieldsThe country-aware address fields, seeded from AddressForm's context.

The form-level error sits at the end of this part rather than in the footer, so it reads above the actions wherever the caller places them.

No props of its own.

AddressFormSubmitThe form's submit button — never disabled (per the evolve-feature-form pattern: submission is gated by validity in the form's onSubmit, not by a disabled control).
PropTypeDescription
pendingLabelReactNode

Shown while a submit is in flight.

isPending?boolean

The action is running. The button keeps its label, so its width and accessible name hold, shows a spinner and is aria-busy. It stays focusable rather than disabled, so focus is not dropped mid-save; clicks and the form submit it would trigger are ignored. Not supported with asChild.

variant?ButtonVariant
tone?ButtonTone
isIconOnly?boolean

The button has no visible label, so it is square. Structural rather than a size: it says the button has nothing to be wide for, and says nothing about how big — the brand's control tokens still decide that.

More

Not inferred from the children. A kebab menu is as often a glyph or an SVG as an <Icon>, and a visually-hidden label — the accessible thing to add — would break any :only-child predicate silently.

asChild?boolean
data-component?undefined

A button's identity is not a caller's to rename — see buttonProps. Typed away so a call site renaming it fails to compile rather than passing an attribute that is silently discarded.

Any other props go to the root element, like on a plain HTML element.

CountrySelectCountry picker whose value is the 2-letter ISO code and whose labels are localized region names from Intl.DisplayNames, sorted with Intl.Collator because alphabetical order differs per locale; preferredCountries come first.
PropTypeDescription
defaultValue?string
required?boolean
autoComplete?string

The autofill hint for the hidden native select, such as country.

name?string
disabled?boolean
value?string
data-component?undefined

Not a caller's to rename — see controlProps.

onValueChange?((value: string) => void)

Hands over the chosen value. Not onChange (that hands over an event) and not RAC's onSelectionChange (that hands over a Key, which is the primitive's vocabulary, not ours).

invalid?boolean
description?ReactNode
labelClassName?string
placement?SelectPlacement
countries?readonly string[]

The selectable ISO-3166-1 alpha-2 codes. Defaults to the list from AddressConfigProvider (every ISO region when unconfigured).

preferredCountries?readonly string[]

Listed first, in this order, such as the store's country; the rest follow by name. Defaults to AddressConfigProvider's preferredCountries.

label?ReactNode
placeholder?string

Any other props go to the root element, like on a plain HTML element.

Examples

EditSeeded (edit), NL — postal code first, no region.
// `defaultValues` is what makes edit mode work; the country in it picks the
// layout, so a Dutch address renders postal-code-first with no region field.
<AddressForm
defaultValues={toDefaults(address)}
onValidSubmit={save}
>
{/* … */}
</AddressForm>;
Open in Storybook ↗
United StatesUS — the region field appears and is required; street name comes first.
// Nothing is configured for this: `COUNTRY_FIELD_RULES` makes `region`
// required for US/CA/AU/IN/BR, and the layout map puts the number after the
// street name. Picking the country is the whole API.
<AddressForm defaultValues={{ country: "US" }} onValidSubmit={save}>
{/* … */}
</AddressForm>;
Open in Storybook ↗
Minimal Field SetA client shipping four countries with salutation, company and email off.
// Wrap the app once. `false` switches a field off entirely rather than hiding
// it, so the schema drops it too — a hidden-but-validated field is an
// unfixable form.
<AddressConfigProvider
countries={["NL", "BE", "DE", "FR"]}
fields={{ company: false, email: false, salutation: false }}
>
<AddressForm onValidSubmit={save}>{/* … */}</AddressForm>
</AddressConfigProvider>;
Open in Storybook ↗
Custom LayoutA custom layout for one country, supplied by the consumer.
// One country's row order, overridden. Unlisted countries keep the shipped
// layout, so this is an override rather than a replacement.
<AddressConfigProvider
countries={["NL", "US"]}
layout={{
NL: [["city"], ["streetName", "streetNumber"], ["postalCode"]],
}}
>
<AddressForm onValidSubmit={save}>{/* … */}</AddressForm>
</AddressConfigProvider>;
Open in Storybook ↗
Supplied NameA name the caller already holds: its fields are left out, and the submitted values carry it.
<AddressForm supplied={{ givenName, familyName }} onValuesChange={setDraft} onValidSubmit={save}>…</AddressForm>;
Open in Storybook ↗
Company On RequestfieldsOnRequest keeps an optional field behind an "Add …" button until the shopper asks for it.
// Most shoppers have no company; the few who do ask for the field.
<AddressConfigProvider fieldsOnRequest={["company"]}>
<AddressForm onValidSubmit={save}>{/* … */}</AddressForm>
</AddressConfigProvider>;
Open in Storybook ↗