#Forms
Alepha provides useForm for schema-driven forms with Zod validation, automatic input generation, and lifecycle events.
#Basic Usage
1import { z } from "alepha"; 2import { useForm } from "alepha/react/form"; 3 4const LoginForm = () => { 5 const form = useForm({ 6 schema: z.object({ 7 email: z.email(), 8 password: z.text(), 9 }),10 handler: async (values) => {11 await api.login(values);12 },13 });14 15 return (16 <form {...form.props}>17 <input {...form.input.email.props} />18 <input {...form.input.password.props} type="password" />19 <button type="submit">Login</button>20 </form>21 );22}
Spread form.props on the <form> element and form.input.<field>.props on each input. The props include name, type, onChange, required, defaultValue, and other attributes derived from the schema.
#useForm Options
| Option | Type | Description |
|---|---|---|
schema |
ZObject |
Zod schema defining fields and validation. |
handler |
(values) => unknown |
Called on submit with validated values. |
initialValues |
Partial<Infer<T>> |
Pre-populate fields with existing data. |
keepDirty |
boolean (default true) |
When initialValues changes (a refetch after a save), keep the fields the user edited instead of overwriting them. |
id |
string |
Prefix for field IDs and data-testid attributes. |
onChange |
(key, value, store) => void |
Called on every field change. |
onError |
(error) => void |
Called when submission throws an error. Marks the error handled, so ActionErrorToaster does not toast it. |
onReset |
() => void |
Called when the form is reset. |
onCreateField |
(name, schema) => InputHTMLAttributes |
Customize generated input attributes. |
The second argument to useForm is a dependency array (defaults to []). When dependencies change, the form is re-created. To switch the form to another record (editing A, then B), put the record id in the dependencies rather than swapping initialValues. The latest handler, onError, onChange, onReset and onCreateField are always used, so a handler may close over render state.
#FormModel
useForm returns a FormModel<T> instance with the following API:
#form.props
Spread on the <form> element. Includes:
id: unique form identifiernoValidate: set totrue(validation is handled by the schema)method:"post", so a submit that happens before hydration (slow bundle, Enter in a text field) never serialises the fields, a password included, into the URLonSubmit: callsform.submit()withpreventDefaultonReset: callsform.reset()
#form.input
A proxy object where each key corresponds to a schema property. Each field has:
| Property | Type | Description |
|---|---|---|
props |
InputHTMLAttributes |
Spread on the <input> element. |
path |
string |
JSON pointer path (e.g., /email). |
required |
boolean |
Whether the field is required. |
schema |
ZType |
The Zod schema for this field. |
set |
(value: any) => void |
Programmatically set the field value. |
initialValue |
any |
The field's initial value (from initialValues or schema defaults). |
items |
Record<string, InputField> |
Child fields, for object-typed properties - see Nested Object Fields. |
form |
FormModel |
Reference back to the parent form. |
#form.submit()
Triggers form submission programmatically. Coerces the input strings to the schema's types and validates them, then calls the handler with the decoded values. A second submit while one is in flight is ignored.
#form.reset(event)
Restores the form to its initial values (empty if no initialValues were given) and emits a form:reset event.
#form.currentValues
Returns the current form values as a restructured object (nested keys like address.city become { address: { city: ... } }).
#Submission state
To react to an in-progress submission (e.g. disable the submit button), use the useFormState hook:
1const { loading } = useFormState(form, ["loading"]);
#Automatic Type Detection
Input types are automatically inferred from the schema:
| Schema Type | Input Type |
|---|---|
z.integer() |
number |
z.number() |
number |
z.boolean() |
checkbox |
z.text() |
text |
Field named password |
password |
Field named email |
email |
Field named url |
url |
z.date() |
date |
z.dateRange() |
range picker (@alepha/ui Control only) |
z.time() |
time |
z.datetime() |
datetime-local |
z.binary() |
file |
Note that email/password/url are detected from the field name, not the schema - a z.email() schema on a field named contact renders as plain text.
String constraints like maxLength and minLength are also applied to the input attributes.
#Nested Object Fields
For schemas with nested objects, use items to access child fields:
1const form = useForm({ 2 schema: z.object({ 3 address: z.object({ 4 street: z.text(), 5 city: z.text(), 6 }), 7 }), 8 handler: async (values) => { /* values.address.street, values.address.city */ }, 9});10 11// Access nested fields:12<input {...form.input.address.items.street.props} />13<input {...form.input.address.items.city.props} />
#Tracking Form State
Use useFormState to reactively track loading, dirty, error, and value states:
1import { useFormState } from "alepha/react/form"; 2 3const MyForm = () => { 4 const form = useForm({ /* ... */ }); 5 const { loading, dirty, error } = useFormState(form); 6 7 return ( 8 <form {...form.props}> 9 {/* inputs */}10 {error && <p>{error.message}</p>}11 <button type="submit" disabled={loading || !dirty}>12 {loading ? "Saving..." : "Save"}13 </button>14 </form>15 );16}
useFormState options:
The first argument is the form model (or { form, path } to track a specific field). The second argument is an array of keys to track, ["loading", "dirty", "error"] by default (values is only tracked when asked for):
1// Track only loading and error2const { loading, error } = useFormState(form, ["loading", "error"]);3 4// Track a specific field's error5const { error } = useFormState({ form, path: "/email" }, ["error"]);6 7// Track current values8const { values } = useFormState(form, ["values"]);
Return type:
| Property | Type | Description |
|---|---|---|
loading |
boolean |
True during form submission. |
dirty |
boolean |
True after any field change. Resets on successful submit and on reset. |
error |
Error | undefined |
Error from the last failed submit. |
values |
Record | undefined |
Current form values (updated on change and submit). |
#Reading Values
useForm keeps values in the model, not in React state, so typing does not re-render the form. Subscribe only where a component needs them:
useFieldValue(form.input.email)returns[value, setValue]for one field and re-renders on that field only.useFormValues(form)returns every current value and re-renders on every change. Keep it for live previews and debug panels.<FormState form={form}>{({ loading, dirty }) => ...}</FormState>is a render-prop wrapper arounduseFormState.
useFormQuerySync(form, { keys: ["status", "q"] }) mirrors the listed fields to the URL query (?status=new&q=foo) and back, so a filter form survives a reload, a shared link and back/forward. It replaces the history entry rather than pushing one, and leaves query params it does not own alone.
All four are exported from alepha/react/form.
#With @alepha/ui
Raw <input {...props}> is the bare layer. In an application using @alepha/ui, render each field with Control from @alepha/ui/form, which picks the widget from the schema (select, date, date range, number, switch, upload, ...) and shows the label, description and field error. Flags force a variant (<Control input={form.input.status} select />). AutoForm renders a whole form from the schema.
1import { Control } from "@alepha/ui/form";2 3<form {...form.props}>4 <Control input={form.input.email} />5 <Control input={form.input.role} select />6</form>;
A picker with local state is still a form: make it a one-field useForm (initialValues for what the server says, onChange to save on change) rather than a hand-built control.
#Form Events
Forms emit events on the Alepha event system:
| Event | Payload | Description |
|---|---|---|
form:changed |
{ id, path, value, initial? } |
A field value changed (initial: true marks programmatic resets). |
form:reset |
{ id } |
Form was reset. |
form:submit:begin |
{ id } |
Submission started. |
form:submit:success |
{ id, values } |
Submission succeeded. |
form:submit:error |
{ id, error } |
Submission failed. |
form:submit:end |
{ id } |
Submission finished (always). |
Forms also emit react:action:begin, react:action:success, react:action:error, and react:action:end events with type: "form", so global action handlers apply to form submissions too.
The react:action:error of a form carries handled: true in two cases, and @alepha/ui's ActionErrorToaster does not toast it: the form was given an onError, or the error is a FormValidationError with a field path, whose message is already shown under that field. Any other failure, including a FormValidationError with no path, still toasts.
#FormValidationError
Throw a FormValidationError in your handler to report field-level validation errors:
1import { FormValidationError } from "alepha/react/form"; 2 3handler: async (values) => { 4 const exists = await api.checkEmail(values.email); 5 if (exists) { 6 throw new FormValidationError({ 7 message: "Email already in use", 8 path: "/email", 9 });10 }11};
The path is a JSON pointer matching the field path (e.g., /email, /address/city). The message is shown under that field and not as a toast: the error is marked handled (see Form Events).
The form's own schema refuses input the same way: a required field left empty or a value out of range fails with a FormValidationError too, before the handler runs. So a react:action:error listener can tell a person's input from a fault by the error alone: a FormValidationError is a refusal, while a SchemaValidationError thrown from inside the handler (a response that broke its own schema, say) is left as it is. The @alepha/lore browser sigil relies on that, and does not report a refusal as a crash.