Form Components
Form components scale based on coordination requirements.
The Problem
When building forms, developers often fall into the trap of state micromanagement. They lift the state of every single input up to the parent view, resulting in massive prop-drilling (value={state.name} and onChange={...}) and forcing the entire view to re-render on every keystroke. Alternatively, they rely on complex third-party hook libraries that abstract away the DOM entirely.
In the AIR Stack, events are an opt-in side effect. Inputs should own their two-way bindings directly. A parent component shouldn't need an imperative onChange handler just to make an input work.
What This Page Covers
This page establishes a definitive Form Progression. You will learn how to build isolated two-way inputs, when to graduate to specialized data handlers, and how to use Context to coordinate complex forms without prop-drilling.
Two-Way Inputs
When an input does not need coordination with a larger form, keep it isolated. It only needs to maintain a two-way binding with the provided value. Because component properties are natively reactive, the component manages its own state by mutating props.value directly.
Input Field
The foundation is a single-purpose wrapper around the native HTML <input>. It standardizes styling and operates autonomously—parent components do not need imperative handlers to make the input functional.
import { setup, render } from '@anchorlib/react';
import type { Bindable } from '@anchorlib/react';
type InputProps = {
type?: string;
value?: Bindable<string>;
onChange?: (value: string) => void;
};
export const InputField = setup<InputProps>((props) => {
const handleInput = (e: React.ChangeEvent<HTMLInputElement>) => {
props.value = e.currentTarget.value;
props.onChange?.(props.value);
};
return render(() => (
<input
type={props.type || 'text'}
value={props.value ?? ''}
onInput={handleInput}
className="form-input"
/>
));
});import { setup } from '@anchorlib/solid';
import type { Bindable } from '@anchorlib/solid';
type InputProps = {
type?: string;
value?: Bindable<string>;
onChange?: (value: string) => void;
};
export const InputField = setup<InputProps>((props) => {
const handleInput = (e: Event) => {
const val = (e.currentTarget as HTMLInputElement).value;
props.value = val;
props.onChange?.(val);
};
return (
<input
type={props.type || 'text'}
value={props.value ?? ''}
onInput={handleInput}
class="form-input"
/>
);
});Specialized Input
When the data requires parsing or specific fallback logic, graduate to a specialized input component. For example, a NumberInput needs to handle the transition between strings and numbers, alongside safe NaN fallbacks.
We use an internal mutable state specifically for the raw input text to prevent cursor jumping while typing decimals, but we still write the final parsed number back to props.value.
import { setup, render, mutable } from '@anchorlib/react';
import type { Bindable } from '@anchorlib/react';
type NumberProps = {
value?: Bindable<number>;
min?: number;
onChange?: (value: number) => void;
};
export const NumberInput = setup<NumberProps>((props) => {
const raw = mutable(String(props.value ?? ''));
const handleInput = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.currentTarget.value;
raw.value = val;
const parsed = parseFloat(val);
if (!isNaN(parsed)) {
props.value = parsed;
props.onChange?.(parsed);
} else if (val === '') {
props.value = props.min ?? 0;
props.onChange?.(props.value);
}
};
return render(() => (
<input
type="number"
value={raw.value}
onInput={handleInput}
className="form-input number"
/>
));
});import { setup, mutable } from '@anchorlib/solid';
import type { Bindable } from '@anchorlib/solid';
type NumberProps = {
value?: Bindable<number>;
min?: number;
onChange?: (value: number) => void;
};
export const NumberInput = setup<NumberProps>((props) => {
const raw = mutable(String(props.value ?? ''));
const handleInput = (e: Event) => {
const val = (e.currentTarget as HTMLInputElement).value;
raw.value = val;
const parsed = parseFloat(val);
if (!isNaN(parsed)) {
props.value = parsed;
props.onChange?.(parsed);
} else if (val === '') {
props.value = props.min ?? 0;
props.onChange?.(props.value);
}
};
return (
<input
type="number"
value={raw.value}
onInput={handleInput}
class="form-input number"
/>
);
});Usage
Because these inputs manage their own mutations, parent components use two-way binding to sync values. Anchor provides the $bind() primitive for both React and SolidJS to automatically link the component's internal mutations back to the parent state.
import { setup, mutable, $bind } from '@anchorlib/react';
import { InputField, NumberInput } from './components';
export const ProfileSettings = setup(() => {
const state = mutable({ name: '', age: 18 });
return (
<div className="settings">
<InputField value={$bind(state, 'name')} placeholder="Your Name" />
<NumberInput value={$bind(state, 'age')} min={18} />
</div>
);
});import { setup, mutable, $bind } from '@anchorlib/solid';
import { InputField, NumberInput } from './components';
export const ProfileSettings = setup(() => {
const state = mutable({ name: '', age: 18 });
return (
<div class="settings">
<InputField value={$bind(state, 'name')} placeholder="Your Name" />
<NumberInput value={$bind(state, 'age')} min={18} />
</div>
);
});Form Components
When multiple inputs must be submitted together and validated, graduate to a Form coordinator.
Instead of forcing the parent page to manage the state of every field, the Form component acts as the State Owner. It takes a Zod schema and initial data, creates the reactive form state, and shares it with its children using setContext().
import { setup, render, setContext, form } from '@anchorlib/react';
import { FormContext } from './context';
export const Form = setup((props) => {
const [state, errors] = form(props.schema, props.data);
setContext(FormContext, { state, errors });
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
props.onSubmit?.(state);
};
return render(() => (
<form
onSubmit={handleSubmit}
className="form-container"
>
{props.children}
</form>
));
});import { setup, setContext, form } from '@anchorlib/solid';
import { FormContext } from './context';
export const Form = setup((props) => {
const [state, errors] = form(props.schema, props.data);
setContext(FormContext, { state, errors });
const handleSubmit = (e: Event) => {
e.preventDefault();
props.onSubmit?.(state);
};
return (
<form
onSubmit={handleSubmit}
class="form-container"
>
{props.children}
</form>
);
});Context Key
To prevent key collisions across different libraries or nested components, it is recommended to use a Symbol to define your context keys.
// context.ts
export const FormContext = Symbol('FormContext');
export const FieldContext = Symbol('FieldContext');Form Field Components
When inputs need to display validation errors and labels automatically, graduate to a FormField component.
The FormField acts as a structural bridge. It uses getContext() to connect to the Form, extracting its own errors based on the name prop. It then sets a local FieldContext so the underlying input knows which field it belongs to in the state object.
import { setup, render, derived, getContext, setContext, $use } from '@anchorlib/react';
import { FormContext, FieldContext } from './context';
export const FormField = setup((props) => {
const form = getContext<Record<string, unknown>>(FormContext);
const error = derived(() => form?.errors[props.name as string]?.message);
setContext(FieldContext, { name: props.name });
return render(() => (
<div className="form-field">
{props.label && <label>{props.label}</label>}
{props.children}
{error.value && <span className="error-message">{error.value}</span>}
</div>
));
});import { setup, getContext, setContext } from '@anchorlib/solid';
import { FormContext, FieldContext } from './context';
export const FormField = setup((props) => {
const form = getContext<Record<string, unknown>>(FormContext);
const error = derived(() => form?.errors[props.name as string]?.message);
setContext(FieldContext, { name: props.name });
return (
<div class="form-field">
{props.label && <label>{props.label}</label>}
{props.children}
{error.value && <span class="error-message">{error.value}</span>}
</div>
);
});Context-Aware Inputs
Finally, upgrade your primitive InputField and NumberInput components to automatically bind using getContext(). If they are placed inside a FormField, they completely bypass the need for explicit props by directly reading and writing to the form's state.
import { setup, render, getContext, derived } from '@anchorlib/react';
import { FormContext, FieldContext } from './context';
export const Input = setup((props) => {
const form = getContext(FormContext);
const field = getContext(FieldContext);
const withForm = form && field;
const output = derived(() => withForm ? form.state[field.name] : props.value);
const handleInput = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.currentTarget.value;
if (withForm) {
form.state[field.name] = val;
} else {
props.value = val;
}
props.onChange?.(val);
};
return render(() => (
<input
type={props.type || 'text'}
value={output.value ?? ''}
onInput={handleInput}
className="form-input"
/>
));
});import { setup, getContext, derived } from '@anchorlib/solid';
import { FormContext, FieldContext } from './context';
export const Input = setup((props) => {
const form = getContext(FormContext);
const field = getContext(FieldContext);
const withForm = form && field;
const output = derived(() => withForm ? form.state[field.name] : props.value);
const handleInput = (e: Event) => {
const val = (e.currentTarget as HTMLInputElement).value;
if (withForm) {
form.state[field.name] = val;
} else {
props.value = val;
}
props.onChange?.(val);
};
return (
<input
type={props.type || 'text'}
value={output.value ?? ''}
onInput={handleInput}
class="form-input"
/>
);
});Final Result
By applying this progression, you achieve a highly composable, zero-boilerplate form architecture. The parent view simply provides the initial data and receives the validated payload on submit, while the form structure defines itself declaratively.
<Form schema={UserSchema} data={{ email: '', age: 18 }} onSubmit={saveProfile}>
<FormField name="email" label="Email Address">
<Input type="email" />
</FormField>
<FormField name="age" label="Age">
<NumberInput min={18} />
</FormField>
<button type="submit">Save Changes</button>
</Form>Typed Form Component
While the previous setup perfectly decouples your state, it lacks type safety. The name prop on FormField is just a string, meaning typos won't be caught by TypeScript.
To solve this, you can graduate to a Form Factory. By passing your schema and initial data to a factory function, you can generate a Form component and a Form.Field component that explicitly restrict the name prop to the keys of your schema.
Building the Factory
The factory uses the exact same FormContext and FieldContext Symbols from earlier, meaning your globally defined Input components will still work perfectly inside it.
import { setup, render, setContext, getContext, form } from '@anchorlib/react';
import { FormContext, FieldContext } from './context';
import type { ZodSchema, z } from 'zod';
export function createForm<T extends ZodSchema>(schema: T, init: z.infer<T>) {
const Form = setup<{ onSubmit?: (data: z.infer<T>) => void }>((props) => {
const [state, errors] = form(schema, init);
setContext(FormContext, { state, errors });
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
props.onSubmit?.(state);
};
return render(() => (
<form onSubmit={handleSubmit} className="form-container">
{props.children}
</form>
));
});
const Field = setup<{ name: keyof z.infer<T>; label?: string }>((props) => {
const form = getContext<Record<string, unknown>>(FormContext);
const error = derived(() => form?.errors[props.name as string]?.message);
setContext(FieldContext, { name: props.name });
return render(() => (
<div className="form-field">
{props.label && <label>{props.label}</label>}
{props.children}
{error.value && <span className="error-message">{error.value}</span>}
</div>
));
});
Form.Field = Field;
return Form;
}import { setup, setContext, getContext, form } from '@anchorlib/solid';
import { FormContext, FieldContext } from './context';
import type { ZodSchema, z } from 'zod';
export function createForm<T extends ZodSchema>(schema: T, init: z.infer<T>) {
const Form = setup<{ onSubmit?: (data: z.infer<T>) => void }>((props) => {
const [state, errors] = form(schema, init);
setContext(FormContext, { state, errors });
const handleSubmit = (e: Event) => {
e.preventDefault();
props.onSubmit?.(state);
};
return (
<form onSubmit={handleSubmit} class="form-container">
{props.children}
</form>
);
});
const Field = setup<{ name: keyof z.infer<T>; label?: string }>((props) => {
const form = getContext<Record<string, unknown>>(FormContext);
const error = derived(() => form?.errors[props.name as string]?.message);
setContext(FieldContext, { name: props.name });
return (
<div class="form-field">
{props.label && <label>{props.label}</label>}
{props.children}
{error.value && <span class="error-message">{error.value}</span>}
</div>
);
});
Form.Field = Field;
return Form;
}Factory Usage
Now, your forms are strictly typed. If you try to add a Field with a name that doesn't exist in the schema, TypeScript will throw an error immediately.
import { z } from 'zod';
import { createForm } from './form-factory';
import { Input, NumberInput } from './components';
const UserSchema = z.object({
email: z.string().email(),
age: z.number().min(18)
});
const UserForm = setup(() => {
const Form = createForm(UserSchema, { email: '', age: 18 });
const saveProfile = () => {
console.log('Profile saved!');
};
return (
<Form onSubmit={saveProfile}>
<Form.Field name="email" label="Email Address">
<Input type="email" />
</Form.Field>
<Form.Field name="age" label="Age">
<NumberInput min={18} />
</Form.Field>
<button type="submit">Save Changes</button>
</Form>
);
});Learn More
- Styling — When a concern needs its own state, behavior, and reactivity
- Static UI — When UI should remain inline, and when it should graduate
- Reactive UI — Presenting reactive data without owning it
- Form Components — User-driven form components with built-in validation