AIR Form vs. Modular Forms
← Back to Posts | Technical Comparison | Modular Forms AIR Form
Modular Forms is a highly performant, type-safe form library specifically built for the SolidJS and Qwik ecosystems. It takes full advantage of Solid's fine-grained reactivity to deliver an excellent developer experience without the rendering overhead typically seen in React.
AIR Form shares the exact same performance characteristics (fine-grained reactivity, zero virtual DOM diffing overhead). However, while Modular Forms relies on a hook-based architecture with explicit adapters, AIR Form provides a highly declarative, component-first approach driven directly by a Zod schema.
Framework comparison
Both libraries provide exceptional performance and deep type safety, but their integration patterns differ.
| Aspect | Modular Forms | AIR Form |
|---|---|---|
| Ecosystem | SolidJS & Qwik | SolidJS via Anchor |
| API | Hooks (createForm, <Field>) | Components from createForm() |
| Validation | Valibot (default) / Zod via adapters | Zod schema (createForm(zod)) |
| State | SolidJS Signals / Stores | Anchor reactive proxy (mutable()) |
Application Setup
Both libraries separate form definition from the UI, but Modular Forms uses a hook-based approach while AIR Form generates a component namespace.
Modular Forms Setup
In Modular Forms, you call createForm inside your component to get the form instance, and use the Field component with render props to wire the inputs.
// components/login-form.tsx (SolidJS)
import { createForm, zodForm } from '@modular-forms/solid';
import { z } from 'zod';
const loginSchema = z.object({
email: z.string().email('Please enter a valid email.'),
password: z.string().min(8, 'Must be at least 8 characters.'),
});
type LoginForm = z.infer<typeof loginSchema>;
export function LoginForm() {
const [loginForm, { Form, Field }] = createForm<LoginForm>({
validate: zodForm(loginSchema),
});
return (
<Form onSubmit={(values) => login(values)}>
<Field name="email">
{(field, props) => (
<div>
<label>Email</label>
<input {...props} type="email" value={field.value} />
{field.error && <span>{field.error}</span>}
</div>
)}
</Field>
<Field name="password">
{(field, props) => (
<div>
<label>Password</label>
<input {...props} type="password" value={field.value} />
{field.error && <span>{field.error}</span>}
</div>
)}
</Field>
<button type="submit" disabled={loginForm.submitting}>
Login
</button>
</Form>
);
}Modular Forms relies on passing props (containing name, ref, onInput, onChange, onBlur) directly to the DOM element via the render prop.
AIR Form Setup
In AIR Form, the schema defines the components outside the setup boundary, keeping the component body extremely clean. The syntax is identical whether you are using @anchorlib/react or @anchorlib/solid.
// components/login-form.tsx (SolidJS)
import { setup, mutable } from '@anchorlib/solid';
import { createForm, EmailInput, PasswordInput, FormSubmit } from '@airlib/solid-form';
import { z } from 'zod';
const LoginForm = createForm(z.object({
email: z.string().email('Please enter a valid email.'),
password: z.string().min(8, 'Must be at least 8 characters.'),
}));
export const Login = setup(() => {
const state = mutable({ email: '', password: '' });
return (
<LoginForm value={state} onSubmit={(data) => login(data)}>
<LoginForm.Field name="email" label="Email">
<EmailInput />
</LoginForm.Field>
<LoginForm.Field name="password" label="Password">
<PasswordInput />
</LoginForm.Field>
<FormSubmit>Login</FormSubmit>
</LoginForm>
);
});Because <EmailInput> is an autonomous component, it automatically wires itself to the parent <LoginForm.Field> context. There are no render props or prop-spreading required.
Reactivity and State
Modular Forms State
Modular Forms uses Solid's createStore under the hood. You access values reactively inside the Field render prop, or imperatively using helper functions like getValue and getValues passing the form instance.
import { getValue, getValues } from '@modular-forms/solid';
// Read a single field reactively inside a component
const email = () => getValue(loginForm, 'email');
// Read the entire form state
const data = getValues(loginForm);AIR Form State
AIR Form uses Anchor's reactive proxy engine (mutable()).
<input
value={state.email}
onInput={(e) => state.email = e.currentTarget.value}
/>Because Anchor handles reactivity via proxies, there are no special helper functions like getValue or getValues required. You simply mutate and read standard JavaScript properties, and the DOM updates automatically.
Form Composition
Modular Forms Components
To build reusable form components in Modular Forms, you wrap the Field component and its render prop logic.
import { Field } from '@modular-forms/solid';
export function TextInput(props) {
return (
<Field name={props.name}>
{(field, fieldProps) => (
<div class="input-wrapper">
<label>{props.label}</label>
<input {...fieldProps} type={props.type || "text"} value={field.value} />
{field.error && <span class="error">{field.error}</span>}
</div>
)}
</Field>
);
}AIR Form Components
AIR Form allows you to construct forms directly using the generated namespace, avoiding the need to build intermediate wrapper components.
<LoginForm.Field name="email" label="Email">
<div class="input-wrapper">
<EmailInput />
</div>
</LoginForm.Field>Alternatively, you can extract a headless input context for completely bespoke designs:
import { formInput } from '@airlib/form';
import { setup } from '@anchorlib/solid';
export const CustomSlider = setup<{ name: string }>((props) => {
const input = formInput(props, {
parse: (v) => Number(v),
stringify: (v) => String(v)
});
return (
<div class="slider">
<input
type="range"
value={input.value}
onInput={(e) => input.value = e.currentTarget.value}
onBlur={() => input.settled()}
/>
</div>
);
});Submitting Changed Data
A common requirement for settings or profile forms is to submit only the fields the user actually modified (a PATCH request) rather than sending the entire payload.
Modular Forms' Dirty State
Modular Forms provides a dirty state, but SubmitHandler only receives the complete values object. You must manually diff the submitted values against your initial state or iterate over field states to construct a partial payload.
const handleSubmit = async (values) => {
// You must write your own logic to extract changes
const changes = deepDiff(initialState, values);
await api.patch('/profile', changes);
};
<Form onSubmit={handleSubmit}>AIR Form's Native Diffing
AIR Form's onSubmit handler natively provides a changes parameter as its second argument. The proxy engine automatically calculates the exact diff against the initial state and provides an object containing only the mutated data.
<ProfileForm
value={state}
onSubmit={async (data, changes) => {
// `changes` natively contains only the modified fields
await api.patch('/profile', changes);
}}
>Boilerplate and Configuration
When scaling a form library, you face two distinct configuration challenges: wiring the validation behavior (the "logic") and standardizing the design system (the "UI").
Behavioral Configuration
Modular Forms requires you to pass specific adapters to wire up validation schemas (like Zod) because it natively favors Valibot.
import { createForm, zodForm } from '@modular-forms/solid';
const [loginForm, { Form, Field }] = createForm({
validate: zodForm(schema)
});AIR Form, conversely, requires zero behavioral configuration. It is built strictly for Zod. The reactive proxy continuously triggers Zod validation in O(1) time without needing to configure or pass validation adapters.
const Form = createForm(schema);UI and Styling Configuration
Modular Forms provides headless validation primitives but lacks a global styling engine. To achieve global UI consistency, developers must manually extract headless inputs and wrap them in custom components.
// Modular Forms forces you to build wrappers like this
export function CustomInput(props) {
return (
<Field name={props.name}>
{(field, props) => (
<div class="flex flex-col gap-1">
<label class="text-sm font-medium">{props.label}</label>
<input class="px-3 py-2 border rounded" {...props} type="text" />
{field.error && <span class="text-red-500 text-xs">{field.error}</span>}
</div>
)}
</Field>
);
}AIR Form solves this at the engine level using a global configureForm API. You define your design system's classes and error states once at the root of your application.
import { configureForm } from '@airlib/solid-form';
configureForm({
field: { class: 'flex flex-col gap-1', errorClass: 'text-red-500 text-xs' },
textInput: { class: 'px-3 py-2 border rounded', errorClass: 'border-red-500 bg-red-50' }
});When you use <TextInput /> anywhere in your app, it automatically inherits these classes and applies the errorClass when the schema validation fails, completely eliminating the need for custom wrapper components.
Final thoughts
Both libraries solve the performance issues that have plagued React form libraries for years, but they target different architectural goals.
Choose Modular Forms if: You prefer functional hook patterns (createForm), explicitly configuring validation adapters, and managing form state via explicit helper functions (getValue).
Choose AIR Form if: You value the declarative, schema-driven <Field> component pattern, zero-config validation, and prefer to manage state through direct, native proxy mutations.