---
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>
  );
};
```
