alepha@docs:~/framework/docs/guides/frontend$
cat 4-forms.md | pretty
7 min read
Last commit:

#Forms

Alepha provides useForm for schema-driven forms with Zod validation, automatic input generation, and lifecycle events.

#Basic Usage

typescript
 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 identifier
  • noValidate: set to true (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 URL
  • onSubmit: calls form.submit() with preventDefault
  • onReset: calls form.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.

Flowchart diagramyesnonoyesthrowsresolvesform.submitAlready submitting?Ignored, with a warningreact:action:begin,form:submit:beginValues match the schema?FormValidationErrorhandler valuesonError, react:action:error,form:submit:errorreact:action:success,form:submit:successreact:action:end, form:submit:end

#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:

typescript
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:

typescript
 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:

typescript
 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):

typescript
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 around useFormState.

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.

tsx
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:

typescript
 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.