alepha@docs:~/docs/guides/frontend$
cat 4-forms.md | pretty
6 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.
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.

#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)
  • 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. Validates values against the schema, then calls the handler. Prevents concurrent submissions.

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

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.
error Error | undefined Error from the last failed submit.
values Record | undefined Current form values (updated on change and submit).

#Form Events

Forms emit events on the Alepha event system:

Event Payload Description
form:change { 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.