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.
useForm HookOne hook to rule them all:
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.
Your TypeBox schema does double duty:
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.
useForm Returns 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
The field() method returns props for standard inputs:
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")} />
Use dot notation for nested objects:
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>}
For arrays, use index notation:
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};
Errors come in two flavors: field-level and form-level.
Access via form.errors:
1{form.errors.email && (2 <span className="error">{form.errors.email}</span>3)}
Handle submission errors:
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>}
Customize via the schema:
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});
Pre-fill the form:
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});
Update when props change:
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});
Control when validation runs:
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});
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>
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};
Forms automatically prevent double-submission. While form.loading is true, form.submit is a no-op.
1<button type="submit" disabled={form.loading || !form.dirty}>2 {form.loading ? "Saving..." : "Save"}3</button>
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});
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};
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};
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