wcz-layout
Version:
332 lines (263 loc) • 8.84 kB
Markdown
---
name: forms-validation
description: >
Build forms with useLayoutForm hook (primary) and withLayoutForm for
composable sub-forms. 13 pre-registered MUI field components via
form.AppField: TextField, NumberField, Autocomplete, Checkbox, Switch,
RadioGroup, Slider, DatePicker, DateRangePicker, TimePicker,
TimeRangePicker, DateTimePicker, DateTimeRangePicker. SubmitButton in
form.AppForm. Zod onChange validators. FormOmittedProps type. Activate
when creating or modifying forms with validation.
type: core
library: wcz-layout
library_version: "7.6.1"
sources:
- "wcz-layout:src/hooks/FormHooks.ts"
- "wcz-layout:src/components/form/"
- "wcz-layout:src/lib/utils.ts"
references:
- "references/field-components.md"
---
# Forms & Validation
## Setup
Import the form hook and Zod schema:
```typescript
import { useLayoutForm } from "wcz-layout/hooks";
import { TodoSchema } from "~/schemas/todo";
```
## Core Patterns
### Basic form with useLayoutForm
```typescript
import { useLayoutForm } from "wcz-layout/hooks";
import { TodoSchema } from "~/schemas/todo";
import type { Todo } from "~/schemas/todo";
import { todoCollection } from "~/lib/db/collections/todoCollection";
import { uuidv7 } from "uuidv7";
function TodoForm() {
const form = useLayoutForm({
defaultValues: {
id: uuidv7(),
name: "",
description: "",
isCompleted: false,
} as Todo,
validators: {
onChange: TodoSchema,
},
onSubmit: ({ value }) => {
todoCollection.insert(value);
},
});
return (
<form.AppForm>
<form.AppField
name="name"
children={(field) => <field.TextField label="Name" required />}
/>
<form.AppField
name="description"
children={(field) => <field.TextField label="Description" multiline rows={3} />}
/>
<form.AppField
name="isCompleted"
children={(field) => <field.Checkbox label="Completed" />}
/>
<form.SubmitButton />
</form.AppForm>
);
}
```
### Available field components
All 13 field components are accessed through `field.*` inside `form.AppField`:
| Component | MUI base | Use case |
| --------------------------- | --------------------------- | ----------------------------- |
| `field.TextField` | TextField | Text input, multiline |
| `field.NumberField` | TextField (number) | Numeric input |
| `field.Autocomplete` | Autocomplete | Search/select with options |
| `field.Checkbox` | FormControlLabel + Checkbox | Boolean toggle |
| `field.Switch` | FormControlLabel + Switch | Boolean toggle (switch) |
| `field.RadioGroup` | RadioGroup | Single selection from options |
| `field.Slider` | Slider | Range/value slider |
| `field.DatePicker` | DatePicker | Date only |
| `field.DateRangePicker` | DateRangePicker | Date range |
| `field.TimePicker` | TimePicker | Time only |
| `field.TimeRangePicker` | TimeRangePicker | Time range |
| `field.DateTimePicker` | DateTimePicker | Date + time |
| `field.DateTimeRangePicker` | DateTimeRangePicker | Date + time range |
See references/field-components.md for detailed prop surfaces.
### Edit form with existing data
```typescript
function TodoEditForm({ todo }: { todo: Todo }) {
const form = useLayoutForm({
defaultValues: todo,
validators: {
onChange: TodoSchema,
},
onSubmit: ({ value }) => {
todoCollection.update(value);
},
});
return (
<form.AppForm>
<form.AppField
name="name"
children={(field) => <field.TextField label="Name" required />}
/>
<form.SubmitButton />
</form.AppForm>
);
}
```
### Sub-form composition with withLayoutForm
Use `withLayoutForm` when splitting a large form into reusable sub-form components:
```typescript
import { withLayoutForm } from "wcz-layout/hooks";
import { TodoSchema } from "~/schemas/todo";
const TodoDetailsSubForm = withLayoutForm({
defaultValues: { name: "", description: "" },
render: ({ form }) => (
<>
<form.AppField
name="name"
children={(field) => <field.TextField label="Name" required />}
/>
<form.AppField
name="description"
children={(field) => <field.TextField label="Description" multiline />}
/>
</>
),
});
```
### Autocomplete with API data
```typescript
<form.AppField
name="assigneeId"
children={(field) => (
<field.Autocomplete
label="Assignee"
options={users}
getOptionLabel={(user) => user.displayName}
isOptionEqualToValue={(option, value) => option.id === value.id}
/>
)}
/>
```
### Date pickers
```typescript
<form.AppField
name="dueDate"
children={(field) => <field.DatePicker label="Due Date" />}
/>
<form.AppField
name="dateRange"
children={(field) => (
<field.DateRangePicker
localeText={{ start: "Start Date", end: "End Date" }}
/>
)}
/>
```
## Common Mistakes
### CRITICAL Using raw MUI TextField instead of form.AppField
Wrong:
```typescript
<TextField
name="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
```
Correct:
```typescript
<form.AppField
name="title"
children={(field) => <field.TextField label="Title" />}
/>
```
`useLayoutForm` pre-registers all field components. Using raw MUI inputs bypasses TanStack Form state management, validation, and error display.
Source: wcz-layout:src/hooks/FormHooks.ts
### HIGH Passing name/value/onChange to AppField components
Wrong:
```typescript
<form.AppField
name="title"
children={(field) => (
<field.TextField
label="Title"
name="title"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
)}
/>
```
Correct:
```typescript
<form.AppField
name="title"
children={(field) => <field.TextField label="Title" />}
/>
```
`FormOmittedProps` explicitly strips `name`, `value`, `onChange`, `onBlur`, `error`, `helperText`, `renderInput`, `type`, and `aria-label`. These are managed internally by `useFieldContext`. Passing them causes conflicts or is silently ignored.
Source: wcz-layout:src/lib/utils.ts
### HIGH Not wrapping SubmitButton in form.AppForm
Wrong:
```typescript
<div>
<form.AppField name="name" children={(field) => <field.TextField label="Name" />} />
<form.SubmitButton /> {/* Outside AppForm — crashes */}
</div>
```
Correct:
```typescript
<form.AppForm>
<form.AppField name="name" children={(field) => <field.TextField label="Name" />} />
<form.SubmitButton />
</form.AppForm>
```
`SubmitButton` uses `useFormContext()` to read `canSubmit` and `isSubmitting` state. It must be rendered inside `form.AppForm`.
Source: wcz-layout:src/components/form/FormSubmitButton.tsx
### HIGH Using useMemo or useCallback in form components
Wrong:
```typescript
const handleSubmit = useCallback(() => form.handleSubmit(), [form]);
```
Correct:
```typescript
const handleSubmit = () => form.handleSubmit();
```
React Compiler handles memoization. Manual `useMemo` / `useCallback` is forbidden per project conventions.
Source: copilot-instructions.md
Cross-skill: See also skills/ui-pages/SKILL.md § Common Mistakes
### MEDIUM FormRadioGroup numeric values become strings
Wrong:
```typescript
<field.RadioGroup
options={[
{ label: "Low", value: 1 },
{ label: "High", value: 2 },
]}
/>
// field.state.value is "1" not 1
```
Correct:
```typescript
<field.RadioGroup
options={[
{ label: "Low", value: "1" },
{ label: "High", value: "2" },
]}
/>
// Use string values consistently, or convert in onSubmit
```
Radio group `onChange` always returns `event.target.value` as a string. Numeric option values round-trip as strings unless explicitly converted.
Source: wcz-layout:src/components/form/FormRadioGroup.tsx
### HIGH Tension: Type safety vs. rapid prototyping
Quick forms using `useState` + manual validation bypass the enforced pattern. Always use `useLayoutForm` + Zod schema derived from Drizzle — even for simple forms. The boilerplate pays off in type safety and consistency.
See also: skills/database-schema/SKILL.md § Common Mistakes
---
See also:
- skills/database-schema/SKILL.md — Zod schemas used as form validators.
- skills/dialogs-notifications/SKILL.md — Form submissions typically show notifications.
- skills/ui-pages/SKILL.md — Create/edit pages embed forms.