Form Handling
SkillMediaBuild forms using TanStack Form with Zod — `formOptions`, `useForm` with `validators`, render-prop `form.Field`, and try/catch submission with toast errors
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Form Handling skill
What this skill tells your AI
The instructions your AI receives, as published by cliqrelay/cliqrelay in .agents/skills/frontend/form-handling/SKILL.md and read by ahel’s review.
This skill covers
@tanstack/react-form.
Every form follows a consistent pattern:
- Schema — Define a Zod schema for validation
- Options — Create shared form options with
formOptions() - Form — Set up
useFormwithvalidators - Fields — Use
form.Fieldwith render prop +FieldInfofor errors - Submit — Handle submission wrapped in
try/catchwith toast feedback
Zod Schema + Type Inference
Define the schema and infer the TypeScript type:
import { z } from "zod";
const formSchema = z.object({
email: z.string().trim().email(),
password: z
.string()
.trim()
.min(8, "Must be at least 8 characters")
.max(32, "Must be at most 32 characters")
});
type FormSchema = z.infer<typeof formSchema>;
Rules:
- Schema and type are co-located in the component file (not extracted)
- Use
z.infer<typeof formSchema>— never write the type manually - Use
.trim()on all string fields - Provide user-facing error messages in
.min(),.max(), etc.
Shared Form Options with formOptions
When the same form config is reused across multiple locations, create shared options with formOptions():
import { formOptions } from "@tanstack/react-form";
export const loginFormOptions = formOptions({
defaultValues: {
email: "",
password: ""
},
validators: {
onChange: formSchema
}
});
For single-use forms, inline the config directly in useForm.
Rules:
- Use
formOptionswhen the form config is shared across components/tests - Inline config in
useFormwhen the form is used in one place only
useForm Setup with Zod Validators
import { useForm } from "@tanstack/react-form";
const form = useForm({
validators: {
onChange: formSchema
},
defaultValues: {
email: "",
password: ""
}
});
Basic Fields with form.Field Render Prop
Every field uses form.Field with a children render prop:
<form.Field
name="email"
children={(field) => (
<label>
Email:
<input
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
type="text"
/>
<FieldInfo field={field} />
</label>
)}
/>
Rules:
field.state.value— current field valuefield.handleChange(value)— set the field valuefield.handleBlur()— mark field as touchedfield.state.meta.isTouched— has the field been blurred?field.state.meta.errors— array of validation error strings
FieldInfo Helper Component
Extract error rendering into a reusable FieldInfo component:
type FieldInfoProps = {
field: {
state: {
meta: {
isTouched: boolean;
errors: string[];
};
};
};
};
function FieldInfo({ field }: FieldInfoProps) {
if (!field.state.meta.isTouched || field.state.meta.errors.length === 0) {
return null;
}
return (
<span role="alert">{field.state.meta.errors.join(", ")}</span>
);
}
Select / Complex Widget Fields
For selects and other complex widgets, use field.handleChange directly:
<form.Field
name="category"
children={(field) => (
<label>
Category:
<select
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
>
<option value="">Select...</option>
<option value="car">Car</option>
<option value="truck">Truck</option>
</select>
<FieldInfo field={field} />
</label>
)}
/>
Field Wrapper Components
For consistent field layout, create a wrapper that accepts a TanStack Field component as a child:
type FormFieldWrapperProps = {
label: string;
children: React.ReactNode;
error?: string;
};
function FormFieldWrapper({ label, children, error }: FormFieldWrapperProps) {
return (
<label>
{label}:
{children}
{error && <span role="alert">{error}</span>}
</label>
);
}
Usage:
<form.Field
name="email"
children={(field) => (
<FormFieldWrapper
label="Email"
error={
field.state.meta.isTouched
? field.state.meta.errors.join(", ")
: undefined
}
>
<input
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
/>
</FormFieldWrapper>
)}
/>
Inline Validators
For field-level validation that is not in the schema, pass a validator function:
<form.Field
name="confirmPassword"
validators={{
onChange: ({ value }) =>
value !== form.getFieldValue("password")
? "Passwords must match"
: undefined
}}
children={(field) => (
<label>
Confirm Password:
<input
type="password"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
/>
<FieldInfo field={field} />
</label>
)}
/>
Listeners for Side Effects
Use listeners.onChange to run side effects when form values change:
const form = useForm({
defaultValues: {
country: "",
city: ""
},
validators: {
onChange: formSchema
},
listeners: {
onChange: ({ formApi }) => {
const country = formApi.getFieldValue("country");
if (country) {
// Fetch cities for the selected country
fetchCities(country);
}
}
}
});
Reactive Subscriptions with useStore / form.Subscribe
Subscribe to form-level state outside of fields using useStore:
import { useStore } from "@tanstack/react-form";
function SubmitButton() {
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Submitting..." : "Submit"}
</button>
);
}
Or use form.Subscribe inline:
<form.Subscribe
selector={(state) => ({ isSubmitting: state.isSubmitting, isValid: state.isValid })}
children={({ isSubmitting, isValid }) => (
<button type="submit" disabled={!isValid || isSubmitting}>
{isSubmitting ? "Submitting..." : "Submit"}
</button>
)}
/>
Change Detection
Detect whether form values have changed from defaults using useStore:
const [isDirty, setIsDirty] = useState(false);
useEffect(() => {
const unsub = form.store.subscribe(() => {
setIsDirty(form.state.isDirty);
});
return unsub;
}, []);
Or using the form.Subscribe selector:
<form.Subscribe
selector={(state) => state.isDirty}
children={(isDirty) =>
isDirty ? <span>Unsaved changes</span> : null
}
/>
Reset Pattern
Reset the form to its default values:
<button type="button" onClick={() => form.reset()}>
Reset
</button>
Call form.reset() after successful submission to clear the form:
const handleFormSubmit = async (data: FormSchema) => {
try {
await onSubmit(data);
form.reset();
} catch (error: any) {
showToastError("Error", error.message ?? "An error occurred");
}
};
Array Fields
For dynamic lists (e.g., images, features), use mode="array":
<form.Field mode="array" name="images">
{(field) => (
<fieldset>
<legend>Images</legend>
{field.state.value.map((_, index) => (
<form.Field
key={index}
name={`images[${index}].url`}
children={(subField) => (
<div>
<input
value={subField.state.value}
onChange={(e) => subField.handleChange(e.target.value)}
onBlur={subField.handleBlur}
placeholder="Image URL"
/>
<button
type="button"
onClick={() => field.removeValue(index)}
>
Remove
</button>
</div>
)}
/>
))}
<button
type="button"
onClick={() => field.pushValue({ url: "" })}
>
Add Image
</button>
</fieldset>
)}
</form.Field>
Rules:
field.pushValue(value)— append an itemfield.removeValue(index)— remove an item at an index- Nest
form.Fieldinside the array field for each sub-field - Use the array index as the
keyprop on nested field components
Submission with Error Handling
type Props = {
onSubmit: (email: string, password: string) => Promise<void>;
onError?: (message: string) => void; // Wire up your toast library here
};
// Inside the component:
const form = useForm({
validators: { onChange: formSchema },
defaultValues: { email: "", password: "" },
onSubmit: async ({ value }) => {
try {
await onSubmit(value.email, value.password);
form.reset();
} catch (error: any) {
onError?.(error.message ?? "An error occurred");
}
}
});
Wiring the form element:
<form onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}>
{/* fields */}
<form.Subscribe
selector={(state) => ({
isSubmitting: state.isSubmitting,
isValid: state.isValid
})}
children={({ isSubmitting, isValid }) => (
<button type="submit" disabled={!isValid || isSubmitting}>
{isSubmitting ? "Submitting..." : "Sign In"}
</button>
)}
/>
</form>
Rules:
- Use
form.handleSubmit()— do NOT callonSubmitdirectly - Always wrap the submit body in
try/catch - Always show the error via the
onErrorcallback - Subscribe to
isSubmittingandisValidto control the submit button - Call
form.reset()after successful submission
Full Example
import { useForm, useStore } from "@tanstack/react-form";
import { z } from "zod";
const formSchema = z.object({
email: z.string().trim().email(),
password: z.string().trim().min(8).max(32)
});
type FormSchema = z.infer<typeof formSchema>;
type FieldInfoProps = {
field: {
state: {
meta: {
isTouched: boolean;
errors: string[];
};
};
};
};
function FieldInfo({ field }: FieldInfoProps) {
if (!field.state.meta.isTouched || field.state.meta.errors.length === 0) {
return null;
}
return <span role="alert">{field.state.meta.errors.join(", ")}</span>;
}
type Props = {
buttonText: string;
onSubmit: (email: string, password: string) => Promise<void>;
onError?: (message: string) => void; // Wire up your toast library here
};
export default function LoginForm({ buttonText, onSubmit, onError }: Props) {
const form = useForm({
validators: { onChange: formSchema },
defaultValues: { email: "", password: "" },
onSubmit: async ({ value }) => {
try {
await onSubmit(value.email, value.password);
form.reset();
} catch (error: any) {
onError?.(error.message);
}
}
});
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
const isValid = useStore(form.store, (state) => state.isValid);
return (
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}
>
<form.Field
name="email"
children={(field) => (
<label>
Email:
<input
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
type="text"
/>
<FieldInfo field={field} />
</label>
)}
/>
<form.Field
name="password"
children={(field) => (
<label>
Password:
<input
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
type="password"
/>
<FieldInfo field={field} />
</label>
)}
/>
<button
type="submit"
disabled={!isValid || isSubmitting}
>
{isSubmitting ? "Loading..." : buttonText}
</button>
</form>
);
}
Rules Summary
✅ DO
- Define schema + inferred type at the top of the component
- Pass Zod schema to
validators.onChangeonuseForm - Use
form.Fieldwith render prop for all fields - Use
field.state.value,field.handleChange,field.handleBlurfor field binding - Extract a
FieldInfocomponent for error rendering - Use
useStore(form.store, selector)orform.Subscribefor reactive subscriptions - Wrap the submit body in
try/catchwith anonErrorcallback - Subscribe to
isSubmittingandisValidfor submit button state - Use
form.reset()after successful submission - Use
formOptions()for shared form configurations - Use
mode="array"withpushValue/removeValuefor dynamic lists
❌ DON'T
- Don't access errors from a separate
errorsobject — read fromfield.state.meta.errors - Don't write TypeScript types manually — use
z.infer<typeof formSchema> - Don't skip
defaultValues— TanStack Form needs them - Don't call
onSubmitdirectly on the form — useform.handleSubmit() - Don't use raw inputs without
FieldInfo— always show validation errors
Signals
- GitHub stars
- 40
- Forks
- 1
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
form-handling- Source
- github.com/cliqrelay/cliqrelay