Skip to content

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.

ts
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'),
});
ts
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.

tsx
import { createForm } from '@airlib/react-form';

const UserForm = createForm(userSchema);
tsx
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.

tsx
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>
  ));
});
tsx
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 htmlFor pointing 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.

ts
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'),
});
ts
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.

tsx
<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),
    });
  }}
>
tsx
<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:

  1. The form enters pending state
  2. All inputs become disabled — prevents user edits during network requests
  3. Concurrent submissions are blocked
  4. On success, the changed state resets — submitted data becomes the new baseline
  5. 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:

  1. The form is pending (currently submitting)
  2. The form is invalid (violates the schema)
  3. 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.

tsx
import { FormSubmit, FormReset } from '@airlib/react-form';

<div>
  <FormReset className="btn-secondary">Undo Changes</FormReset>
  <FormSubmit className="btn-primary">Save Profile</FormSubmit>
</div>
tsx
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.

tsx
<FormSubmit className="btn-primary">
  {(form) => (
    <>
      {form?.pending && <Spinner />}
      <span>{form?.pending ? 'Saving...' : 'Save Profile'}</span>
    </>
  )}
</FormSubmit>
tsx
<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