alouette
alouette-forms
Validated forms over react-hook-form. Form owns the instance and passes its control down, so the form type is written once and every field's value type is inferred; FormField labels one field, renders any input through render and reports its error; FormFieldArray repeats a group of fields with its own add and remove; FormSubmitButton drives the submit lifecycle (spinner, success, inline error) and sits in a DecisionBar beside Cancel; SimpleVForm is the vertical-stack shortcut. FormEditableItem and FormEditableSection edit a row or a section in a modal owning its own Form, so cancelling is an unmount, never a restore. The controls themselves are alouette-inputs. Load when building a form with validation or a submit button, a repeatable list of fields, or an edit-in-a-modal row.
This skill builds on alouette-inputs (the controls a field renders) and alouette-actions (the ActionButton lifecycle the submit button reuses). Read them first.
alouette — Forms
react-hook-form underneath: Form owns the form instance and hands { control, submit } to its subtree, FormField binds one field to a labelled, error-aware row and renders any input — the controls of alouette-inputs/SKILL.md — and the submit button reuses the async-action lifecycle.
control is how the types flow, exactly as in react-hook-form's own Controller: write the form type once on <Form<Values>>, then pass its control to each field. The field infers the form type from the control and its own value type from name, so field.value is that one field's type — never a union of every field, and never a per-field type argument.
<Form<Values> … render={({ control }) => <FormField control={control} name="age" … />} />
// field.value is Values["age"], inferredValidated forms
SimpleVForm is the common case: a vertical stack of fields plus a trailing submit button.
import {
SimpleVForm,
FormField,
InputText,
FormValidationError,
} from "alouette";
interface Values {
name: string;
email: string;
}
function submitErrorToMessage(error: unknown): string {
if (error instanceof FormValidationError)
return "Please fix the errors above.";
return error instanceof Error ? error.message : "Something went wrong.";
}
<SimpleVForm<Values>
defaultValues={{ name: "", email: "" }}
submitLabel="Submit"
submitErrorToMessage={submitErrorToMessage}
onSubmit={async (values) => saveToServer(values)}
render={({ control }) => (
<FormField
control={control}
name="name"
label="Name"
required="Name is required."
render={({ field, labelId, describedBy, invalid, required }) => (
<InputText
ref={field.ref}
value={field.value}
aria-labelledby={labelId}
aria-describedby={describedBy}
aria-required={required}
invalid={invalid}
onChangeText={field.onChange}
onBlur={field.onBlur}
/>
)}
/>
)}
/>;FormField wiring
FormField renders any input through render — it is not tied to InputText. The rendered input must wire the field bindings and FormItem's state:
- control={control} — from the enclosing Form's render params; types name and field.value.
- ref={field.ref} — lets pressing the label focus the input (via react-hook-form setFocus).
- value={field.value} / onChangeText={field.onChange} / onBlur={field.onBlur}.
- aria-labelledby={labelId}, aria-describedby={describedBy}, invalid={invalid} and aria-required={required} — FormItem's state (label, details + error ids, danger border + aria-invalid, required); every alouette field takes them.
required doubles as the empty-field message: true shows the marker with no message; any other ReactNode is the message shown once the field is left empty. validate takes a react-hook-form validator (returns an error string or undefined) and sits beside required on the same FormField; its v is the field's value type, so a number field's validator takes a number without a cast. For rich/non-string error content, use renderError.
validate={(v) => (/^[^@]+@[^@]+$/.test(v) ? undefined : "Enter a valid email.")}Custom layout with Form
When the layout isn't a plain vertical stack, use Form directly: render receives { control, submit }, and a FormSubmitButton takes submit. Fields split into their own component take a control: Control<Values> prop, not useFormContext. Example in references/custom-form-layout.md.
Repeatable item lists and edit-in-a-modal rows
FormFieldArray wraps useFieldArray and owns the array label, add/remove buttons and minSize padding; render gets the item's path prefix (typed `${name}.${number}`) and composes its own FormFields from it. FormEditableItem (an EditableItem row) and FormEditableSection (an EditableSection section, for a value of several lines) share one editor: a modal owning its own Form, mounted per open — so cancelling is an unmount, not a restore — and one editor prop set, exported as FormEditorProps. All three are detailed in references/field-arrays-and-editable-rows.md.
Submit lifecycle
FormSubmitButton is built on ActionButton, so submitting shows the spinner / success / failed overlay and an inline error automatically. submit() rejects with FormValidationError when fields are invalid (nothing was submitted), so errorToMessage can render "fix the errors above" distinctly from a real onSubmit failure.
Next to a Cancel, the submit button goes in a DecisionBar (alouette-actions), inline as in a modal, so every form orders its actions the same way:
<DecisionBar
dismissText="Cancel"
onDismiss={close}
primary={
<FormSubmitButton
label="Save"
errorToMessage={toMessage}
onPress={submit}
/>
}
/>mode defaults to "onTouched". By default an exception thrown from onSubmit propagates (consumed by FormSubmitButton); pass onSubmitError to Form only if you need to observe it elsewhere.
Common Mistakes
HIGH Omitting control, or writing the form type on every FormField
Wrong:
<Form<Values> render={() => <FormField<Values> name="name" ... />} />Correct:
<Form<Values> render={({ control }) => <FormField control={control} name="name" ... />} />control is required and is the only inference site: it types name against the form and makes field.value that one field's type. Writing FormField<Values> instead is an arity error (TName has no default) — deliberately, because an explicit type argument would block TName from ever being inferred from name and degrade field.value to a union of every field in the form. Reaching for useFormContext to avoid threading control gets you the same union.
Source: packages/alouette/src/ui/forms/FormField.tsx; ui/forms/Form.tsx
HIGH Naming FormField's input function children instead of render
Wrong:
<FormField control={control} name="name" label="Name">{({ field }) => <InputText ... />}</FormField>Correct:
<FormField control={control} name="name" label="Name"
render={({ field, labelId }) => <InputText ... />} />The input is supplied through the render prop, not children — a function child is ignored.
Source: packages/alouette/src/ui/forms/FormField.tsx
HIGH Forgetting field.ref or FormItem's render params on the input
Wrong:
render={({ field }) => <InputText value={field.value} onChangeText={field.onChange} />}Correct:
render={({ field, labelId, describedBy, invalid, required }) => (
<InputText ref={field.ref} value={field.value} aria-labelledby={labelId}
aria-describedby={describedBy} aria-required={required} invalid={invalid}
onChangeText={field.onChange} onBlur={field.onBlur} />
)}Without field.ref, pressing the label can't focus the input and setFocus no-ops; without aria-labelledby the input has no accessible name; without invalid / describedBy a field in error keeps a neutral border under its red label and a screen reader never links the message to it.
Source: packages/alouette/src/ui/forms/FormField.tsx; ui/forms/FormItem.tsx
MEDIUM Omitting errorToMessage or hardcoding an English default
Wrong:
<FormSubmitButton label="Save" onPress={submit} /> {/* errorToMessage missing */}Correct:
<FormSubmitButton
label="Save"
onPress={submit}
errorToMessage={submitErrorToMessage}
/>errorToMessage is required (not defaulted) because a library default could only be a hardcoded, untranslatable string. Handle FormValidationError inside it to distinguish invalid fields from a genuine submit failure.
Source: packages/alouette/src/ui/forms/FormSubmitButton.tsx; ui/forms/Form.tsx
MEDIUM Reimplementing the submit button's loading state
Wrong: a raw Button with manual isSubmitting + disabled around submit.
Correct: FormSubmitButton (or SimpleVForm, which adds it for you) — it derives loading/success/failed from the submit promise like any ActionButton.
Source: packages/alouette/src/ui/forms/FormSubmitButton.tsx
MEDIUM Mapping a data array of fields instead of FormFieldArray
Wrong:
{fields.map((f, i) => (
<FormField key={i} control={control} name={`guests.${i}.value`} ... />
))}
<Button text="Add" onPress={() => setFields([...fields, {}])} />Correct: FormFieldArray (see above).
Hand-rolled array state drifts from react-hook-form's own field array (stale indices on remove, no stable key, no built-in add/remove affordances). FormFieldArray wraps useFieldArray and supplies the label, indentation, and add/remove buttons; render only supplies each item's own fields.
Source: packages/alouette/src/ui/forms/FormFieldArray.tsx
HIGH Editing a field of the screen's form inside the modal, then restoring on cancel
Wrong:
const { field } = useController<Values, "diet">({ name: "diet" });
const [editedFrom, setEditedFrom] = useState(field.value);
<IconButton onPress={() => { setEditedFrom(field.value); setEditing(true); }} … />
<Modal visible={editing} onClose={() => { field.onChange(editedFrom); setEditing(false); }}>
<DietFields /> {/* FormFields of the *screen's* form */}
</Modal>Correct: <FormEditableItem defaultValues={{ diet }} onSubmit={save} render={({ control }) => <DietFields control={control} />} /> (see above) — the fields bind to the modal Form's own control, not to the screen's.
Binding the modal's fields to the surrounding form mutates shared state on every keystroke, which is why the value then has to be snapshotted on open and restored on cancel. FormEditableItem mounts a separate Form per open, seeded from defaultValues: cancel is an unmount, and the row keeps showing the last saved value with no restore logic. FormEditableSection is the same mechanism for a whole section.
Source: packages/alouette/src/ui/forms/useFormEditorModal.tsx