Forms with validation
Build accessible, predictable, and production-ready forms using PyColors UI primitives.
OverviewLink to section
Forms are one of the most fragile parts of a SaaS product.
They combine user input, validation rules, async submission, accessibility, loading states, and error handling. A good form pattern makes those responsibilities explicit instead of hiding them inside an opaque abstraction.
This guide shows how to build predictable forms using:
- React Hook Form for form state
- Zod for schema validation
- PyColors UI for accessible interface primitives
Why this matters
Forms are where users directly interact with your product logic. If validation, errors, and loading states feel unclear, trust drops quickly.
What you’ll buildLink to section
Schema-first validation
Keep validation close to the data model with explicit, reusable rules.
Accessible feedback
Show field-level errors, global failures, and loading state without relying only on color.
Production flow
Handle async submission, double-click prevention, and form-level errors predictably.
Mental modelLink to section
Inputs display state. Schemas define truth. Forms coordinate the flow.
Keep these responsibilities separate:
| Layer | Responsibility |
|---|---|
| Schema | Defines what valid data means |
| Form state | Tracks values, errors, dirty state, and submission |
| UI primitives | Render labels, fields, buttons, and feedback |
| Submission logic | Calls the server and handles success or failure |
System rule
PyColors form patterns should stay explicit. Avoid abstractions that hide validation, submission, and accessibility responsibilities behind too many props.
StepsLink to section
Define the schemaLink to section
Validation rules should live close to the data model, not inside the visual input component.
import { z } from "zod";
export const accountFormSchema = z.object({
email: z.string().email("Enter a valid email address"),
password: z
.string()
.min(8, "Password must contain at least 8 characters"),
});
export type AccountFormValues = z.infer<typeof accountFormSchema>;This gives you a reusable contract for client-side validation and server-side validation.
Connect React Hook FormLink to section
React Hook Form coordinates field values, validation output, and submission state.
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import {
accountFormSchema,
type AccountFormValues,
} from "@/lib/forms/account-schema";
const form = useForm<AccountFormValues>({
resolver: zodResolver(accountFormSchema),
defaultValues: {
email: "",
password: "",
},
});Implementation note
Keep default values explicit. It makes the form easier to reason about, test, and reset after successful submission.
Render fields with explicit errorsLink to section
PyColors UI primitives render the state. They do not own the validation rules.
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<Input
label="Email"
type="email"
autoComplete="email"
{...form.register("email")}
error={form.formState.errors.email?.message}
/>
<Input
label="Password"
type="password"
autoComplete="current-password"
{...form.register("password")}
error={form.formState.errors.password?.message}
/>
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? "Saving…" : "Save changes"}
</Button>
</form>This keeps the component contract simple: the field receives the error message, the form owns validation, and the button reflects submission state.
Add form-level feedbackLink to section
Some errors are not field errors. Network failures, permission issues, and unexpected server errors need form-level feedback.
{formError ? (
<Alert variant="destructive">
<AlertTitle>Submission failed</AlertTitle>
<AlertDescription>{formError}</AlertDescription>
</Alert>
) : null}Use field-level errors for input problems. Use alerts for submission-level problems.
Full exampleLink to section
Account settings
Ready for validation
This preview shows the form surface. The code tab wires validation, errors, and submission state.
"use client";
import * as React from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import {
Alert,
AlertDescription,
AlertTitle,
Button,
Card,
CardContent,
CardHeader,
CardTitle,
Input,
} from "@pycolors/ui";
import {
accountFormSchema,
type AccountFormValues,
} from "@/lib/forms/account-schema";
export function AccountForm() {
const [formError, setFormError] = React.useState<string | null>(null);
const form = useForm<AccountFormValues>({
resolver: zodResolver(accountFormSchema),
defaultValues: {
email: "",
password: "",
},
});
async function onSubmit(values: AccountFormValues) {
setFormError(null);
try {
await saveAccount(values);
form.reset(values);
} catch {
setFormError("We could not save your changes. Please try again.");
}
}
return (
<Card className="rounded-2xl border-border/60">
<CardHeader>
<CardTitle>Account settings</CardTitle>
</CardHeader>
<CardContent>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
>
{formError ? (
<Alert variant="destructive">
<AlertTitle>Submission failed</AlertTitle>
<AlertDescription>{formError}</AlertDescription>
</Alert>
) : null}
<Input
label="Email"
type="email"
autoComplete="email"
{...form.register("email")}
error={form.formState.errors.email?.message}
/>
<Input
label="Password"
type="password"
autoComplete="current-password"
{...form.register("password")}
error={form.formState.errors.password?.message}
/>
<Button
type="submit"
disabled={form.formState.isSubmitting}
>
{form.formState.isSubmitting
? "Saving…"
: "Save changes"}
</Button>
</form>
</CardContent>
</Card>
);
}
async function saveAccount(values: AccountFormValues) {
await new Promise((resolve) => setTimeout(resolve, 600));
return values;
}Decision guideLink to section
Use this pattern if:
- the form has real validation rules
- the form submits data asynchronously
- users need clear field-level feedback
- errors must be accessible and explicit
- the same pattern will appear across product surfaces
Avoid over-abstracting if:
- the form is still changing frequently
- validation logic is hidden inside UI components
- a single form component requires many conditional props
- the abstraction makes debugging harder
- accessibility behavior becomes implicit
Prefer
- schema-driven validation
- field-level errors near the field
- form-level alerts for submission failures
- clear submit button loading state
- small, explicit, predictable form structure
Avoid
- abstracting the entire form too early
- hiding validation rules inside UI primitives
- showing only a toast for blocking form errors
- allowing duplicate submissions
- relying only on color to communicate errors
Production checklistLink to section
Before shipping a form, confirm that:
- every field has a visible label
- validation messages are short and specific
- field errors are displayed near the field
- submit is disabled while the request is running
- async errors are visible in the form, not only in a toast
- keyboard navigation works from start to submit
- server-side validation mirrors the client contract
Ready to move this pattern into a real SaaS flow?
Starter Pro includes production-shaped foundations for auth, billing, protected routes, and product flows. → /docs/starter-pro