wcz-layout
Version:
105 lines (91 loc) • 3.95 kB
Markdown
---
name: forms
description: "Use when building or changing forms, including fields, validation, submission, reset, date values, async initial values, or reusable subforms."
metadata:
type: convention
library: wcz-layout
---
# Form patterns
> Mechanics (`createFormHook`, `withForm`, field API, validation lifecycle) belong to
> TanStack Form v1. `useLayoutForm` is its `useAppForm` and `withLayoutForm` its `withForm`,
> pre-bound to the MUI field set below.
## Rules
- Always use `useLayoutForm` with the pre-defined components.
- Fields are not importable; reach them through render props. `form.AppField` yields
`field.Autocomplete`, `field.Checkbox`, `field.DatePicker`, `field.DateTimePicker`,
`field.NumberField`, `field.RadioGroup`, `field.Slider`, `field.Switch`, `field.TextField`
and `field.TimePicker`. `form.AppForm` yields `form.SubmitButton`, and nothing else. Pick
the field that matches the value.
- Define `width` for all form fields based on the expected content length.
- Reuse Zod schemas from `src/lib/schemas/`, derived from Drizzle tables with `createSelectSchema`.
- `field.DatePicker`, `field.TimePicker` and `field.DateTimePicker` hold `Dayjs`, not `Date`.
A Drizzle `timestamp` column gives you a `Date`, so convert at both boundaries:
`dayjs(row.dueAt)` into `defaultValues`, `.toDate()` on the way out in `onSubmit`.
- Use translation for labels, helper text, validation messages and submit.
- Reset the form after a successful create, and let the submit handler reject on failure so
a failed save keeps what the user typed.
- `SubmitButton` calls `form.handleSubmit()` and owns its loading and disabled state.
- `reset()` restores the `defaultValues` captured when the form was constructed. If those
contain a generated `uuidv7()` id, pass a fresh one to `reset({ ...defaults, id: uuidv7() })`
or the second create collides on the primary key.
- `defaultValues` only re-syncs into a mounted form while it is still untouched. On edit
routes, preload the query in the loader so the row is there before the form mounts.
- When fetching data for a form, always use a suspense query.
- Split a long form with `withLayoutForm`, which binds a sub-form to the same
`defaultValues` shape as the parent. Custom field components read `useFieldContext<T>()`.
## Examples
```tsx
// Form component
interface FormProps {
defaultValues: Feature;
onSubmit: (value: Feature) => Promise<void>;
}
export const Form: FC<FormProps> = ({ defaultValues, onSubmit }) => {
const { t } = useTranslation();
const form = useLayoutForm({
defaultValues,
validators: { onChange: FeatureSchema },
onSubmit: async ({ value, formApi }) => {
await onSubmit(value);
formApi.reset();
},
});
return (
<form
onSubmit={async (event) => {
event.preventDefault();
event.stopPropagation();
await form.handleSubmit();
}}
>
<form.AppField name="property1">
{(field) => <field.TextField label={t("Feature.Property1")} required sx={{ width: 420 }} />}
</form.AppField>
<form.AppField name="property2">
{(field) => (
<field.Autocomplete
options={options}
autoHighlight
autoSelect
autoComplete
loading={isLoading}
sx={{ width: 250 }}
textFieldProps={{ label: t("Feature.Property2"), required: true }}
/>
)}
</form.AppField>
<form.AppField name="property3">
{(field) => (
<field.NumberField label={t("Feature.Property3")} required sx={{ width: 120 }} />
)}
</form.AppField>
<form.AppField name="property4">
{(field) => <field.Switch label={t("Feature.Property4")} />}
</form.AppField>
<form.AppForm>
<form.SubmitButton variant="contained">{t("Submit")}</form.SubmitButton>
</form.AppForm>
</form>
);
};
```