forms
123 linesSource
Use when building or changing forms, including fields, validation, submission, reset, date values, async initial values, or reusable subforms.
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: the route alerts, then rethrows (see the route example). A handler that only alerts resolves, and the form resets anyway.
- 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>
);
};tsx
// Route rendering the form: alert, then rethrow so a failed save keeps the input
<Form
defaultValues={{ id: uuidv7(), property1: "" }}
onSubmit={async (value) => {
try {
const transaction = handleOnSubmit(value);
await transaction.isPersisted.promise;
await navigate({ to: "/features" });
} catch (error) {
if (error instanceof Error) await alert(error.message);
throw error;
}
}}
/>