Skip to content

ShoppingListForm

Shared create/edit form root, built on the evolve-feature-form pattern: a RAC Form whose validationErrors drive display, validated by one exported Zod schema. Clean until the first submit, then live — and the submit button is never disabled. It seeds ShoppingListFormContext so the composed Fields/Submit pieces read the extras without prop-drilling.

import { ShoppingListForm } from "@evolve-storefront/feature-shopping-list/shopping-list-form";

Renders inside ShoppingListProvider.

Preview

CreateOpen in Storybook ↗

Usage

Empty create form — nothing shows until the first submit.

const t = useTranslations("ShoppingList.form.create");
// The dialog owns the footer; the form owns state and the <form>.
<ShoppingListForm onValidSubmit={handleValid} isPending={create.isPending}>
<ShoppingListFormFields />
<DialogFooter>
<ShoppingListFormSubmit pendingLabel={t("pending")}>
{t("submit")}
</ShoppingListFormSubmit>
</DialogFooter>
</ShoppingListForm>;

Props

PropTypeDescription
onValidSubmit(values: { name: string; description?: string | undefined; }) => void

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

childrenReactNode

The composed pieces — typically <ShoppingListFormFields /> and a footer with <ShoppingListFormSubmit>. The form owns state and the <form> element; the caller owns the layout (so a dialog keeps its 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<{ name: string; description?: string | undefined; }>
isPending?boolean
error?string | null

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

serverErrors?Partial<Record<"name" | "description", string | string[]>>

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 ShoppingListForm to arrange its parts yourself. Every part is exported from @evolve-storefront/feature-shopping-list/shopping-list-form.

const t = useTranslations("ShoppingList.form.create");
// The dialog owns the footer; the form owns state and the <form>.
<ShoppingListForm onValidSubmit={handleValid} isPending={create.isPending}>
<ShoppingListFormFields />
<DialogFooter>
<ShoppingListFormSubmit pendingLabel={t("pending")}>
{t("submit")}
</ShoppingListFormSubmit>
</DialogFooter>
</ShoppingListForm>;
ShoppingListFormContextThe context ShoppingListForm fills.

No props of its own.

ShoppingListFormFieldsThe name + optional description fields, seeded from ShoppingListForm's context.

No props of its own.

ShoppingListFormSubmitThe 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 (create/edit provide their own copy).

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.

Examples

EditEdit mode: seeded with the list's current name and description.
const t = useTranslations("ShoppingList.form.edit");
<ShoppingListForm
defaultValues={{ name: list.name, description: list.description ?? "" }}
onValidSubmit={handleValid}
isPending={update.isPending}
>
<ShoppingListFormFields />
<DialogFooter>
<ShoppingListFormSubmit pendingLabel={t("pending")}>
{t("submit")}
</ShoppingListFormSubmit>
</DialogFooter>
</ShoppingListForm>;
Open in Storybook ↗
PendingSubmitting: the button shows the pending label (never disabled).
// Straight off the mutation: the submit swaps to its pending label.
<ShoppingListForm onValidSubmit={handleValid} isPending={create.isPending}>
<ShoppingListFormFields />
<ShoppingListFormSubmit pendingLabel={t("pending")}>
{t("submit")}
</ShoppingListFormSubmit>
</ShoppingListForm>;
Open in Storybook ↗
With ErrorA failed mutation surfaces a form-level error above the actions.
// A form-level message for a failed mutation, shown above the actions.
<ShoppingListForm
onValidSubmit={handleValid}
error={create.isError ? t("error") : null}
>
<ShoppingListFormFields />
<ShoppingListFormSubmit pendingLabel={t("pending")}>
{t("submit")}
</ShoppingListFormSubmit>
</ShoppingListForm>;
Open in Storybook ↗