alepha@docs:~/docs/guides/frontend$
cat 5-form-handling.md
3 min read
Last commit:

#Form Handling

Forms are the tax you pay for user input. Nobody enjoys building them. Validation logic scattered across components, state management nightmares, error messages that appear in the wrong place...

Alepha's useForm hook makes forms almost bearable.

#Basic Usage

tsx
 1import { useForm } from "@alepha/react/form"; 2import { t } from "alepha"; 3  4const LoginForm = () => { 5  const form = useForm({ 6    schema: t.object({ 7      email: t.email(), 8      password: t.text({ minLength: 8 }), 9    }),10    handler: async (values) => {11      // values is fully typed: { email: string, password: string }12      await api.login(values);13    },14  });15 16  return (17    <form {...form.props}>18      <input {...form.input.email.props} placeholder="Email" />19      <input {...form.input.password.props} type="password" placeholder="Password" />20      <button type="submit">Sign In</button>21    </form>22  );23};

That's it. Schema defines the shape. Handler runs on submit. TypeBox validates before the handler even sees the data.

#The Schema

Your schema is the source of truth. It defines:

  • What fields exist
  • What types they are
  • Validation rules (min length, email format, etc.)
typescript
 1const form = useForm({ 2  schema: t.object({ 3    username: t.text({ minLength: 3, maxLength: 20 }), 4    email: t.email(), 5    age: t.integer({ minimum: 18 }), 6    bio: t.optional(t.longText()), 7  }), 8  handler: async (values) => { 9    // TypeScript knows: { username: string, email: string, age: number, bio?: string }10  },11});

If validation fails, the form doesn't submit. The error appears on the field. No manual validation code needed.

#The Handler

The handler is an async function that receives validated values. If it throws, the form handles it.

typescript
 1const router = useRouter<AppRouter>(); 2const auth = useAuth<AppAuth>(); 3  4const form = useForm({ 5  schema: t.object({ 6    email: t.email(), 7    password: t.text(), 8  }), 9  handler: async (values) => {10    try {11      await auth.login(values.email, values.password);12      router.go("/dashboard");13    } catch (error) {14      // Re-throw to let the form handle it15      throw error;16    }17  },18});

#Field-Specific Errors

Sometimes the server tells you something's wrong with a specific field. Use FormValidationError:

typescript
 1import { FormValidationError } from "@alepha/react/form"; 2import { HttpError } from "alepha/server"; 3  4const form = useForm({ 5  schema: t.object({ 6    email: t.email(), 7    password: t.text(), 8  }), 9  handler: async (values) => {10    try {11      await auth.login(values.email, values.password);12    } catch (error) {13      if (error instanceof HttpError && error.error === "InvalidCredentialsError") {14        // Show error on the password field15        throw new FormValidationError({16          message: "Invalid email or password",17          path: "/password",18        });19      }20      throw error;21    }22  },23});

The path uses JSON Pointer syntax. /password targets the password field. /address/city would target a nested field.

#Spreading Props

The magic is in the props. Spread them and everything works:

typescript
1// Form element2<form {...form.props}>3 4// Input fields5<input {...form.input.email.props} />6<input {...form.input.password.props} type="password" />

What's in those props?

  • form.propsonSubmit, onReset, and form identification
  • form.input.fieldName.propsname, value, onChange, onBlur, and validation state

You can add your own props after spreading:

typescript
1<input2  {...form.input.email.props}3  placeholder="you@example.com"4  className="my-input"5/>

#Tracking Form State

Need to know if the form is submitting? If it's been modified? Use useFormState:

typescript
 1import { useForm } from "@alepha/react/form"; 2import { useFormState } from "@alepha/react/form"; 3  4const MyForm = () => { 5  const form = useForm({ 6    schema: t.object({ name: t.text() }), 7    handler: async (values) => { /* ... */ }, 8  }); 9 10  const { loading, dirty, error } = useFormState(form);11 12  return (13    <form {...form.props}>14      <input {...form.input.name.props} />15 16      <button type="submit" disabled={loading}>17        {loading ? "Saving..." : "Save"}18      </button>19 20      {dirty && <span>You have unsaved changes</span>}21      {error && <span className="error">{error.message}</span>}22    </form>23  );24};

#What useFormState Tracks

Property Type Description
loading boolean True while handler is executing
dirty boolean True if any field has changed
error Error | undefined Last submission error
values object | undefined Current form values

You can pick which ones you need:

typescript
1// Only track loading state2const { loading } = useFormState(form, ["loading"]);3 4// Track everything5const { loading, dirty, error, values } = useFormState(form, ["loading", "dirty", "error", "values"]);

#Real-World Example

Here's a login form with all the bells and whistles:

typescript
 1import { useForm, useFormState, FormValidationError } from "@alepha/react/form"; 2import { useRouter } from "@alepha/react/router"; 3import { t } from "alepha"; 4import { HttpError } from "alepha/server"; 5  6const LoginPage = () => { 7  const router = useRouter(); 8  const auth = useAuth(); 9 10  const form = useForm({11    schema: t.object({12      identifier: t.string({ minLength: 1 }),13      password: t.string({ minLength: 6 }),14    }),15    handler: async (data) => {16      try {17        await auth.login({18          username: data.identifier,19          password: data.password,20        });21        await router.go("/");22      } catch (error) {23        if (error instanceof HttpError && error.error === "InvalidCredentialsError") {24          throw new FormValidationError({25            message: "Invalid credentials",26            path: "/password",27          });28        }29        throw error;30      }31    },32  });33 34  const { loading } = useFormState(form, ["loading"]);35 36  return (37    <form {...form.props}>38      <div>39        <label>Username or Email</label>40        <input {...form.input.identifier.props} autoComplete="username" />41      </div>42 43      <div>44        <label>Password</label>45        <input {...form.input.password.props} type="password" autoComplete="current-password" />46      </div>47 48      <button type="submit" disabled={loading}>49        {loading ? "Signing in..." : "Sign In"}50      </button>51    </form>52  );53};

#With @alepha/ui

If you're using the UI kit, forms get even cleaner:

typescript
 1import { useForm } from "@alepha/react/form"; 2import { ActionButton, Control } from "@alepha/ui"; 3import { IconUser, IconLock } from "@tabler/icons-react"; 4import { t } from "alepha"; 5  6const LoginForm = () => { 7  const form = useForm({ 8    schema: t.object({ 9      email: t.email(),10      password: t.text({ minLength: 8 }),11    }),12    handler: async (values) => {13      await auth.login(values);14    },15  });16 17  return (18    <form {...form.props}>19      <Control20        title="Email"21        input={form.input.email}22        icon={IconUser}23      />24      <Control25        title="Password"26        input={form.input.password}27        icon={IconLock}28        password={{ autoComplete: "current-password" }}29      />30      <ActionButton variant="filled" form={form}>31        Sign In32      </ActionButton>33    </form>34  );35};

The Control component handles labels, icons, and error display. The ActionButton automatically disables during submission when you pass it the form.

#TypeForm: Zero-Layout Forms

Don't want to write any JSX for your form fields? TypeForm renders everything automatically from your schema:

tsx
 1import { useForm } from "@alepha/react/form"; 2import { TypeForm } from "@alepha/ui"; 3import { t } from "alepha"; 4  5const UserForm = () => { 6  const form = useForm({ 7    schema: t.object({ 8      username: t.text({ title: "Username" }), 9      email: t.email({ title: "Email Address" }),10      age: t.integer({ title: "Age", minimum: 0, maximum: 120 }),11      role: t.enum(["admin", "user", "guest"], { title: "Role", default: "user" }),12      subscribe: t.boolean({ title: "Subscribe to newsletter", default: false }),13    }),14    handler: async (values) => {15      await api.createUser(values);16    },17  });18 19  return <TypeForm form={form} columns={2} />;20};

That's the whole component. TypeForm inspects your schema and renders appropriate inputs for each field type. Strings get text inputs, integers get number inputs, booleans get checkboxes, enums get dropdowns. The title option in your schema becomes the field label.

#Responsive Columns

Control the layout with the columns prop:

tsx
1// 2 columns on desktop2<TypeForm form={form} columns={2} />3 4// Responsive breakpoints5<TypeForm6  form={form}7  columns={{ xs: 1, sm: 2, lg: 3 }}8/>

#Customizing Fields

Need to tweak specific fields? Use fieldControlProps:

tsx
1<TypeForm2  form={form}3  columns={2}4  fieldControlProps={{5    password: { password: { autoComplete: "new-password" } },6    bio: { textarea: { rows: 5 } },7  }}8/>

#When to Use TypeForm

  • Prototyping — Get a working form in seconds
  • Admin panels — CRUD forms where design doesn't matter
  • Internal tools — When "it works" beats "it's pretty"

For user-facing forms where you need pixel-perfect control, stick with Control and manual layout. For everything else, TypeForm saves you from writing boilerplate.

#Tips

  1. Keep schemas simple — Complex nested objects work, but flat forms are easier to manage
  2. Handle errors in the handler — Transform API errors into FormValidationError for field-specific feedback
  3. Use useFormState sparingly — Only subscribe to the state you actually need
  4. Trust the validation — If the handler runs, the data is valid. Don't re-validate.

Forms still aren't fun. But at least now they're not painful.

On This Page
No headings found...
ready
mainTypeScript
UTF-8guides_frontend_form_handling.md