Getting Started
To build a form with AIR Form, you need three things: a schema, a form factory, and input components. This page walks through each step.
Defining a Schema
The Zod schema is the single source of truth. It defines the data shape, validation rules, and error messages.
import { z } from 'zod';
const userSchema = z.object({
name: z.string().min(3, 'Name is too short'),
email: z.string().email('Invalid email address'),
age: z.number().min(18, 'Must be at least 18'),
});import { z } from 'zod';
const userSchema = z.object({
name: z.string().min(3, 'Name is too short'),
email: z.string().email('Invalid email address'),
age: z.number().min(18, 'Must be at least 18'),
});Every field in the schema maps to a field in the form. Validation runs on every mutation — no manual trigger needed.
Creating a Typed Form
Pass the schema to createForm to generate typed components.
import { createForm } from '@airlib/react-form';
const UserForm = createForm(userSchema);import { createForm } from '@airlib/solid-form';
const UserForm = createForm(userSchema);UserForm is both a component and a namespace. It provides:
<UserForm>— The form wrapper<UserForm.Field>— Typed field wrapper<UserForm.FieldList>— Array field wrapper
All three enforce the schema types. <UserForm.Field name="typo"> is a compile error.
Building the Form
Drop Field components inside the form. Each Field takes a name (the schema path) and wraps an input component.
import { setup, render, mutable } from '@anchorlib/react';
import { TextInput, EmailInput, NumberInput, FormSubmit } from '@airlib/react-form';
export const ProfileEditor = setup(() => {
const profile = mutable({
name: '',
email: '',
age: 18,
});
return render(() => (
<UserForm
value={profile}
onSubmit={(data) => console.log('Saved:', data)}
>
<UserForm.Field name="name" label="Name" errorClass="error">
<TextInput placeholder="Enter name" />
</UserForm.Field>
<UserForm.Field name="email" label="Email" errorClass="error">
<EmailInput placeholder="Enter email" />
</UserForm.Field>
<UserForm.Field name="age" label="Age" errorClass="error">
<NumberInput />
</UserForm.Field>
<FormSubmit>Save Profile</FormSubmit>
</UserForm>
));
});import { setup, mutable } from '@anchorlib/solid';
import { TextInput, EmailInput, NumberInput, FormSubmit } from '@airlib/solid-form';
export const ProfileEditor = setup(() => {
const profile = mutable({
name: '',
email: '',
age: 18,
});
return (
<UserForm
value={profile}
onSubmit={(data) => console.log('Saved:', data)}
>
<UserForm.Field name="name" label="Name" errorClass="error">
<TextInput placeholder="Enter name" />
</UserForm.Field>
<UserForm.Field name="email" label="Email" errorClass="error">
<EmailInput placeholder="Enter email" />
</UserForm.Field>
<UserForm.Field name="age" label="Age" errorClass="error">
<NumberInput />
</UserForm.Field>
<FormSubmit>Save Profile</FormSubmit>
</UserForm>
);
});value provides the initial data. onSubmit receives the validated data when the form submits.
Each Field handles:
- Rendering the label with
htmlForpointing to the input - Displaying validation errors with the
errorClass - Setting accessibility attributes (
aria-invalid,aria-describedby,role="alert")
The input components (TextInput, EmailInput, etc.) connect to the field context — no onChange or value prop needed.
Validation
Validation runs on every field change. Errors appear in the Field when the value violates the schema.
const schema = z.object({
email: z.string().email('Please enter a valid email'),
password: z.string()
.min(8, 'At least 8 characters')
.regex(/[A-Z]/, 'Must contain an uppercase letter')
.regex(/[0-9]/, 'Must contain a number'),
});const schema = z.object({
email: z.string().email('Please enter a valid email'),
password: z.string()
.min(8, 'At least 8 characters')
.regex(/[A-Z]/, 'Must contain an uppercase letter')
.regex(/[0-9]/, 'Must contain a number'),
});Each Zod rule produces an error message. If a field has multiple rules, all failed messages are collected in field.error as an array.
By default, Field renders the first error. For custom rendering, use headless mode.
Handling Submission
The onSubmit handler receives three arguments: the full data, the changed fields, and the original event.
<UserForm
value={existingUser}
onSubmit={async (data, changes, event) => {
// data: { name: 'Alice', email: 'alice@test.com', age: 25 }
// changes: { name: 'Alice' } — only what changed
await fetch('/api/user', {
method: 'PATCH',
body: JSON.stringify(changes),
});
}}
><UserForm
value={existingUser}
onSubmit={async (data, changes, event) => {
// data: { name: 'Alice', email: 'alice@test.com', age: 25 }
// changes: { name: 'Alice' } — only what changed
await fetch('/api/user', {
method: 'PATCH',
body: JSON.stringify(changes),
});
}}
>During submission:
- The form enters
pendingstate - All inputs become
disabled— prevents user edits during network requests - Concurrent submissions are blocked
- On success, the changed state resets — submitted data becomes the new baseline
- On error, the form captures it in
form.error
Submit & Reset Components
AIR Form provides <FormSubmit> and <FormReset> components that automatically connect to the form state.
<FormSubmit> tracks the form's readiness. It disables itself when:
- The form is
pending(currently submitting) - The form is invalid (violates the schema)
- The form is unchanged (no fields have been modified)
Similarly, <FormReset> reverts all fields to their initial values, clears touched states, and resets the submission status. It disables itself when the form is unchanged.
import { FormSubmit, FormReset } from '@airlib/react-form';
<div>
<FormReset className="btn-secondary">Undo Changes</FormReset>
<FormSubmit className="btn-primary">Save Profile</FormSubmit>
</div>import { FormSubmit, FormReset } from '@airlib/solid-form';
<div>
<FormReset class="btn-secondary">Undo Changes</FormReset>
<FormSubmit class="btn-primary">Save Profile</FormSubmit>
</div>Both components also accept a function as children, providing direct access to the form state so you can change their contents dynamically.
<FormSubmit className="btn-primary">
{(form) => (
<>
{form?.pending && <Spinner />}
<span>{form?.pending ? 'Saving...' : 'Save Profile'}</span>
</>
)}
</FormSubmit><FormSubmit class="btn-primary">
{(form) => (
<>
{form?.pending && <Spinner />}
<span>{form?.pending ? 'Saving...' : 'Save Profile'}</span>
</>
)}
</FormSubmit>What's Next
- Form Inputs — Explore the 14 built-in input components
- Composition — Cross-field matching, arrays, headless mode
- Configuration — Global defaults, styling, and behavior overrides
- General Form — Using the untyped Form, Field, Submit, and Reset components
- Core API — Use the engine without components