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.
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.
Your schema is the source of truth. It defines:
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 is an async function that receives validated values. If it throws, the form handles it.
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});
Sometimes the server tells you something's wrong with a specific field. Use FormValidationError:
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.
The magic is in the props. Spread them and everything works:
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.props — onSubmit, onReset, and form identificationform.input.fieldName.props — name, value, onChange, onBlur, and validation stateYou can add your own props after spreading:
1<input2 {...form.input.email.props}3 placeholder="you@example.com"4 className="my-input"5/>
Need to know if the form is submitting? If it's been modified? Use useFormState:
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};
| 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:
1// Only track loading state2const { loading } = useFormState(form, ["loading"]);3 4// Track everything5const { loading, dirty, error, values } = useFormState(form, ["loading", "dirty", "error", "values"]);
Here's a login form with all the bells and whistles:
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};
If you're using the UI kit, forms get even cleaner:
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.
Don't want to write any JSX for your form fields? TypeForm renders everything automatically from your schema:
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.
Control the layout with the columns prop:
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/>
Need to tweak specific fields? Use fieldControlProps:
1<TypeForm2 form={form}3 columns={2}4 fieldControlProps={{5 password: { password: { autoComplete: "new-password" } },6 bio: { textarea: { rows: 5 } },7 }}8/>
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.
FormValidationError for field-specific feedbackuseFormState sparingly — Only subscribe to the state you actually needForms still aren't fun. But at least now they're not painful.