alepha@docs:~/docs/guides/frontend$
cat 4-form.md
2 min read
Last commit:

#Forms

Forms. The thing we all pretend is simple until we need validation, error messages, loading states, and that weird edge case where the user submits twice.

The @alepha/react/form module gives you type-safe forms with validation baked in. Same schemas you use for your API? They validate your forms too.

#The useForm Hook

One hook to rule them all:

tsx
 1import { useForm } from "@alepha/react/form"; 2import { t } from "alepha"; 3  4const CreateUserForm = () => { 5  const form = useForm({ 6    schema: t.object({ 7      name: t.text({ minLength: 2 }), 8      email: t.email(), 9      age: t.integer({ minimum: 18 }),10    }),11    handler: async (values) => {12      await api.users.create(values);13    }14  });15 16  return (17    <form onSubmit={form.submit}>18      <input {...form.field("name")} placeholder="Name" />19      {form.errors.name && <span className="error">{form.errors.name}</span>}20 21      <input {...form.field("email")} placeholder="Email" />22      {form.errors.email && <span className="error">{form.errors.email}</span>}23 24      <input {...form.field("age")} type="number" placeholder="Age" />25      {form.errors.age && <span className="error">{form.errors.age}</span>}26 27      <button type="submit" disabled={form.loading}>28        {form.loading ? "Saving..." : "Create User"}29      </button>30    </form>31  );32};

That's it. Schema defines the shape, form.field() binds inputs, form.errors shows problems, form.loading tracks submission.

#Schema = Validation

Your TypeBox schema does double duty:

typescript
 1const schema = t.object({ 2  // Text with constraints 3  username: t.text({ minLength: 3, maxLength: 20 }), 4  5  // Email validation built-in 6  email: t.email(), 7  8  // Numbers with ranges 9  age: t.integer({ minimum: 18, maximum: 120 }),10 11  // Enums12  role: t.enum(["admin", "user", "guest"]),13 14  // Optional fields15  bio: t.optional(t.text({ maxLength: 500 })),16 17  // Nested objects18  address: t.object({19    street: t.text(),20    city: t.text(),21    zip: t.text({ pattern: "^\d{5}$" }),22  }),23 24  // Arrays25  tags: t.array(t.text(), { minItems: 1, maxItems: 5 }),26});

Validation happens client-side before submission. The same schema validates server-side in your $action.

#Form API

#What useForm Returns

typescript
 1const form = useForm({ schema, handler }); 2  3// Field binding 4form.field("name")           // Returns { name, value, onChange, onBlur } 5form.field("address.city")   // Nested paths work 6  7// State 8form.values                  // Current form values 9form.errors                  // Validation errors by field10form.touched                 // Which fields have been touched11form.loading                 // True during submission12form.dirty                   // True if any field changed13 14// Actions15form.submit(event)           // Form onSubmit handler16form.reset()                 // Reset to initial values17form.setFieldValue(path, v)  // Programmatic update18form.setFieldTouched(path)   // Mark as touched19form.validate()              // Trigger validation manually

#Field Binding

The field() method returns props for standard inputs:

tsx
 1// Text input 2<input {...form.field("name")} /> 3  4// With type 5<input {...form.field("email")} type="email" /> 6  7// Number 8<input {...form.field("age")} type="number" /> 9 10// Checkbox11<input {...form.field("newsletter")} type="checkbox" />12 13// Textarea14<textarea {...form.field("description")} />

#Nested Fields

Use dot notation for nested objects:

tsx
1<input {...form.field("address.street")} placeholder="Street" />2<input {...form.field("address.city")} placeholder="City" />3<input {...form.field("address.zip")} placeholder="ZIP" />4 5{form.errors["address.zip"] && <span>{form.errors["address.zip"]}</span>}

#Array Fields

For arrays, use index notation:

tsx
 1const TagsInput = () => { 2  const tags = form.values.tags || []; 3  4  return ( 5    <div> 6      {tags.map((_, index) => ( 7        <input key={index} {...form.field(`tags.${index}`)} /> 8      ))} 9      <button type="button" onClick={() => {10        form.setFieldValue("tags", [...tags, ""]);11      }}>12        Add Tag13      </button>14    </div>15  );16};

#Error Handling

Errors come in two flavors: field-level and form-level.

#Field Errors

Access via form.errors:

tsx
1{form.errors.email && (2  <span className="error">{form.errors.email}</span>3)}

#Form-Level Errors

Handle submission errors:

tsx
 1const form = useForm({ 2  schema, 3  handler: async (values) => { 4    const result = await api.users.create(values); 5    if (!result.success) { 6      throw new Error(result.message); 7    } 8  }, 9});10 11// In JSX12{form.error && <div className="form-error">{form.error.message}</div>}

#Custom Error Messages

Customize via the schema:

typescript
1const schema = t.object({2  email: t.email({ errorMessage: "Please enter a valid email" }),3  age: t.integer({4    minimum: 18,5    errorMessage: {6      minimum: "You must be at least 18 years old"7    }8  }),9});

#Initial Values

Pre-fill the form:

tsx
 1const form = useForm({ 2  schema, 3  initialValues: { 4    name: user.name, 5    email: user.email, 6    role: user.role, 7  }, 8  handler: async (values) => { 9    await api.users.update(user.id, values);10  },11});

#Dynamic Initial Values

Update when props change:

tsx
1const form = useForm({2  schema,3  initialValues: user,4  resetOnChange: [user.id], // Reset form when user.id changes5  handler: async (values) => {6    await api.users.update(user.id, values);7  },8});

#Validation Modes

Control when validation runs:

tsx
1const form = useForm({2  schema,3  validateOn: "blur",    // Validate on field blur (default)4  // validateOn: "change",  // Validate on every keystroke5  // validateOn: "submit",  // Only validate on submit6  handler: async (values) => { /* ... */ },7});

#Integrating with UI Libraries

#With Custom Input Components

tsx
 1const TextInput = ({ name, label }) => { 2  const form = useFormContext(); 3  const fieldProps = form.field(name); 4  5  return ( 6    <div className="field"> 7      <label>{label}</label> 8      <input {...fieldProps} /> 9      {form.errors[name] && <span className="error">{form.errors[name]}</span>}10    </div>11  );12};13 14// Usage15<form onSubmit={form.submit}>16  <FormProvider value={form}>17    <TextInput name="email" label="Email" />18    <TextInput name="password" label="Password" />19  </FormProvider>20</form>

#With Mantine/MUI

tsx
 1import { TextInput, Button } from "@mantine/core"; 2  3const MyForm = () => { 4  const form = useForm({ 5    schema: t.object({ email: t.email() }), 6    handler: async (v) => { /* ... */ }, 7  }); 8  9  return (10    <form onSubmit={form.submit}>11      <TextInput12        label="Email"13        {...form.field("email")}14        error={form.errors.email}15      />16      <Button type="submit" loading={form.loading}>17        Submit18      </Button>19    </form>20  );21};

#Submit Behavior

#Preventing Double Submit

Forms automatically prevent double-submission. While form.loading is true, form.submit is a no-op.

#Disabling the Button

tsx
1<button type="submit" disabled={form.loading || !form.dirty}>2  {form.loading ? "Saving..." : "Save"}3</button>

#Success Callback

tsx
 1const form = useForm({ 2  schema, 3  handler: async (values) => { 4    const created = await api.users.create(values); 5    return created; // Returned to onSuccess 6  }, 7  onSuccess: (result) => { 8    toast.success("User created!"); 9    router.go("userProfile", { params: { id: result.id } });10  },11  onError: (error) => {12    toast.error(error.message);13  },14});

#Common Patterns

#Login Form

tsx
 1const LoginForm = () => { 2  const form = useForm({ 3    schema: t.object({ 4      email: t.email(), 5      password: t.text({ minLength: 8 }), 6      remember: t.optional(t.boolean()), 7    }), 8    handler: async (values) => { 9      await auth.login(values.email, values.password, values.remember);10    },11    onSuccess: () => router.go("dashboard"),12  });13 14  return (15    <form onSubmit={form.submit}>16      <input {...form.field("email")} type="email" />17      <input {...form.field("password")} type="password" />18      <label>19        <input {...form.field("remember")} type="checkbox" />20        Remember me21      </label>22      <button type="submit" disabled={form.loading}>23        {form.loading ? "Logging in..." : "Login"}24      </button>25    </form>26  );27};

#Multi-Step Form

tsx
 1const MultiStepForm = () => { 2  const [step, setStep] = useState(1); 3  4  const form = useForm({ 5    schema: t.object({ 6      // Step 1 7      name: t.text(), 8      email: t.email(), 9      // Step 210      company: t.text(),11      role: t.text(),12      // Step 313      plan: t.enum(["free", "pro", "enterprise"]),14    }),15    handler: async (values) => {16      await api.onboard(values);17    },18  });19 20  const validateStep = async () => {21    const fieldsPerStep = {22      1: ["name", "email"],23      2: ["company", "role"],24      3: ["plan"],25    };26    const errors = await form.validateFields(fieldsPerStep[step]);27    return Object.keys(errors).length === 0;28  };29 30  const nextStep = async () => {31    if (await validateStep()) {32      setStep(step + 1);33    }34  };35 36  return (37    <form onSubmit={form.submit}>38      {step === 1 && (39        <>40          <input {...form.field("name")} />41          <input {...form.field("email")} />42          <button type="button" onClick={nextStep}>Next</button>43        </>44      )}45      {step === 2 && (46        <>47          <input {...form.field("company")} />48          <input {...form.field("role")} />49          <button type="button" onClick={() => setStep(1)}>Back</button>50          <button type="button" onClick={nextStep}>Next</button>51        </>52      )}53      {step === 3 && (54        <>55          <select {...form.field("plan")}>56            <option value="free">Free</option>57            <option value="pro">Pro</option>58            <option value="enterprise">Enterprise</option>59          </select>60          <button type="button" onClick={() => setStep(2)}>Back</button>61          <button type="submit">Complete</button>62        </>63      )}64    </form>65  );66};

#Quick Reference

typescript
 1// Basic form 2const form = useForm({ 3  schema: t.object({ ... }), 4  handler: async (values) => { ... }, 5}); 6  7// With options 8const form = useForm({ 9  schema,10  initialValues: { ... },11  validateOn: "blur",12  resetOnChange: [dependency],13  handler: async (values) => { ... },14  onSuccess: (result) => { ... },15  onError: (error) => { ... },16});17 18// Field binding19<input {...form.field("name")} />20 21// Errors22{form.errors.name && <span>{form.errors.name}</span>}23 24// Submit25<form onSubmit={form.submit}>26<button disabled={form.loading}>Submit</button>

Previous: Head Management | Next: i18n

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