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

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