GuidesUpdated April 27, 2026

Forms with validation

Build accessible, predictable, and production-ready forms using PyColors UI primitives.

GuidesForms

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:

LayerResponsibility
SchemaDefines what valid data means
Form stateTracks values, errors, dirty state, and submission
UI primitivesRender labels, fields, buttons, and feedback
Submission logicCalls 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.

lib/forms/account-schema.ts
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.

components/account-form.tsx
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.

components/account-form.tsx
<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.

components/account-form.tsx
{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.

components/account-form.tsx
"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

Common questionsLink to section

Next stepsLink to section