Forms with validation
Building accessible, predictable, and production-ready forms using PyColors UI primitives.
Forms are one of the most fragile parts of any product.
They combine:
- user input
- validation rules
- async states
- error handling
- accessibility constraints
This guide explains how to build forms with validation using PyColors UI primitives — without turning forms into opaque abstractions.
Core principles
Before looking at code, a few principles matter more than any library choice.
1. Forms are systems, not components
A form is not a reusable UI component.
It is a composition of:
- inputs
- buttons
- validation rules
- async feedback
Trying to abstract a full form usually leads to:
- over-configuration
- hidden logic
- poor debuggability
In PyColors UI, forms are patterns, not primitives.
2. Validation belongs to the data layer
UI components:
- display values
- display errors
- reflect states
They should not decide what is valid.
Validation rules live:
- close to your schema
- close to your domain logic
The UI consumes the result.
3. Error states must be explicit
Good forms:
- show errors clearly
- do not surprise the user
- do not rely on color alone
- do not hide failure states
Minimal stack (recommended)
This guide uses a minimal, widely adopted stack:
- React Hook Form for form state
- Zod for schema validation
- PyColors UI for rendering
You can swap these tools if needed — the principles stay the same.
Example: basic form with validation
We will build a simple form with:
- required fields
- inline validation errors
- disabled submit while loading
- global error feedback
Schema
Validation rules are defined first.
import { z } from "zod";
export const formSchema = z.object({
email: z.string().email("Invalid email address"),
password: z.string().min(8, "Password must be at least 8 characters"),
});Form setup
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
const form = useForm({
resolver: zodResolver(formSchema),
defaultValues: {
email: "",
password: "",
},
});The form state is now responsible for:
- field values
- validation results
- submission state
Rendering inputs
PyColors Input consumes validation state —
it does not implement validation itself.
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<Input
label="Email"
type="email"
{...form.register("email")}
error={form.formState.errors.email?.message}
/>
<Input
label="Password"
type="password"
{...form.register("password")}
error={form.formState.errors.password?.message}
/>
</form>Why this works
- Errors are explicit
- Inputs remain stateless
- Validation logic stays outside the UI
Submit button states
Buttons reflect form state, they do not control it.
<Button
type="submit"
disabled={form.formState.isSubmitting}
>
{form.formState.isSubmitting ? "Saving…" : "Save"}
</Button>Rules:
- Disable submit while submitting
- Avoid double submissions
- Reflect loading with text, not spinners
Global error feedback
Some errors are not tied to a specific field:
- network failure
- permission issues
- server errors
Use Alert for these cases.
{formStateError && (
<Alert variant="destructive">
<AlertTitle>Submission failed</AlertTitle>
<AlertDescription>
Please try again later.
</AlertDescription>
</Alert>
)}Full example (simplified)
function ExampleForm() {
const form = useForm({
resolver: zodResolver(formSchema),
});
async function onSubmit(values) {
try {
await save(values);
} catch {
setError("Something went wrong");
}
}
return (
<Card>
<CardHeader>Account settings</CardHeader>
<CardContent>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
>
<Input
label="Email"
{...form.register("email")}
error={form.formState.errors.email?.message}
/>
<Input
label="Password"
type="password"
{...form.register("password")}
error={form.formState.errors.password?.message}
/>
<Button
type="submit"
disabled={form.formState.isSubmitting}
>
Save changes
</Button>
</form>
</CardContent>
</Card>
);
}UX rules (production-tested)
Don’t
- Validate on every keystroke by default
- Disable fields without explanation
- Rely on color alone to indicate errors
Do
- Validate on submit and blur
- Keep error messages short and specific
- Always include text feedback
Common anti-patterns
- Abstracting the entire form into a single component
- Hiding validation rules inside UI components
- Showing only a toast for form errors
- Allowing submission while loading
Mental model to keep
- Inputs display state
- Schemas define truth
- Forms coordinate the flow
Keeping these responsibilities separate makes forms:
- easier to reason about
- easier to debug
- easier to evolve