Bad form validation feels like a wall: red text everywhere, fields resetting, cryptic errors, and no clear path to "fix it". Good validation feels like a guide, catching mistakes early, explaining clearly what's wrong, and supporting different input methods and devices.

Principles that keep validation predictable

  • Catch errors early, not late: Validate as users type or on blur, not only on submit.
  • Explain in plain language: No jargon or backend error messages exposed.
  • Keep data safe: Never clear fields on error.
  • Respect accessibility: Screen readers and keyboard users should get the same guidance.
  • Be consistent: Every form behaves the same way across your app.

Login form with inline validation

This pattern uses react-hook-form with Zod, validating on blur so errors appear when the user leaves a field.

import React from "react" import { useForm } from "react-hook-form" import { zodResolver } from "@hookform/resolvers/zod" import * as z from "zod" import { Form, FormField, } from "@/components/ui/primitives/form" import { Field, FieldContent, FieldError, } from "@/components/ui/primitives/field" import { Input } from "@/components/ui/primitives/input" import { Button, ButtonLabel } from "@/components/ui/primitives/button" const loginSchema = z.object({ email: z.string().email("Please enter a valid email address"), password: z .string() .min(6, "Password must be at least 6 characters"), }) type LoginFormData = z.infer<typeof loginSchema> export function LoginForm({ onSubmit }: { onSubmit: (data: LoginFormData) => Promise<void> }) { const { register, handleSubmit, formState: { errors, isSubmitting }, } = useForm<LoginFormData>({ resolver: zodResolver(loginSchema), mode: "onBlur", }) const handleFormSubmit = async (data: LoginFormData) => { await onSubmit(data) } return ( <Form onSubmit={handleSubmit(handleFormSubmit)}> <FormField> <Field isInvalid={!!errors.email}> <FieldContent> <Input type="email" placeholder="Email" isError={!!errors.email} isDisabled={isSubmitting} {...register("email")} /> {errors.email && <FieldError>{errors.email.message}</FieldError>} </FieldContent> </Field> </FormField> <FormField> <Field isInvalid={!!errors.password}> <FieldContent> <Input type="password" placeholder="Password" isError={!!errors.password} isDisabled={isSubmitting} {...register("password")} /> {errors.password && <FieldError>{errors.password.message}</FieldError>} </FieldContent> </Field> </FormField> <Button type="submit" action="primary" className="w-full mt-4" disabled={isSubmitting} > <ButtonLabel>{isSubmitting ? "Logging in..." : "Log in"}</ButtonLabel> </Button> </Form> ) } 

Why this works:

  • Errors attach to fields via Field + FieldError, not a generic banner.
  • Clear copy ("Please enter a valid email address") instead of vague "Invalid input".
  • Loading state (isSubmitting) prevents double submits and disables inputs.
  • Accessible markup: labels, error regions, and focus styles are handled by the primitives.

Inline errors plus error summary for longer forms

Longer forms benefit from both inline errors under each field and an error summary at the top that links to the first problem.

import { FormErrorSummary } from "@/components/ui/primitives/form" function AccountSettingsForm() { const { register, handleSubmit, formState: { errors }, } = useForm<AccountSettingsData>({ resolver: zodResolver(accountSettingsSchema), mode: "onSubmit", }) const onSubmit = async (data: AccountSettingsData) => { /* ... */ } return ( <Form onSubmit={handleSubmit(onSubmit)}> <FormErrorSummary errors={errors} /> {/* fields go here, using Field + FieldError like in LoginForm */} </Form> ) } 

The error summary component maps over the error object and renders links that focus the corresponding field when clicked.

AI-specific validation: prompts, files, and feedback

AI products introduce form-like interactions that go beyond traditional forms: prompt textareas, file uploads, feedback modals.

Prompt input with safe submission

import { PromptInput, type AttachedFile, } from "@/components/ui/composites/prompt-input" function PromptSection() { const [prompt, setPrompt] = React.useState("") const [files, setFiles] = React.useState<AttachedFile[]>([]) const handleSubmit = (value: string, attachedFiles: AttachedFile[]) => { if (!value.trim() && attachedFiles.length === 0) { return } sendToAI(value, attachedFiles) } return ( <PromptInput value={prompt} onChange={setPrompt} onSubmit={handleSubmit} attachedFiles={files} onFileAttach={(newFiles) => { const safeFiles = normalizeFiles(newFiles) setFiles((prev) => [...prev, ...safeFiles]) }} maxFiles={5} acceptedFileTypes=".pdf,.docx,.png,.jpg" actionButton="send" /> ) } 

This pattern:

  • Prevents empty submissions (no prompt, no file).
  • Centralizes file validation logic in normalizeFiles.
  • Uses one component for keyboard, screen reader, and pointer use.

Feedback modal with required rating

import { FeedbackModal } from "@/components/ui/templates/modal/feedback-modal" function ResponseFeedback({ responseId }: { responseId: string }) { const [open, setOpen] = React.useState(false) const handleSubmit = async ({ rating, feedback }: { rating: number; feedback: string }) => { if (rating === 0) { return } await submitFeedback({ responseId, rating, feedback }) setOpen(false) } return ( <> <button type="button" onClick={() => setOpen(true)} className="text-xs text-secondary-600 hover:text-primary-600" > Rate this response </button> <FeedbackModal isOpen={open} onClose={() => setOpen(false)} onSubmit={handleSubmit} /> </> ) } 

The guard if (rating === 0) prevents submission without a selection. The modal handles focus trapping and announces errors to screen readers.

Validation checklist

Use this when designing or reviewing forms:

Clarity

  • Error messages use human language.
  • Errors are placed directly next to fields.
  • Field labels and help text explain what's expected.

Behavior

  • Fields never clear themselves on validation error.
  • Validation runs on blur and submit (not on every keystroke).
  • Submit button shows a clear loading state during requests.

Accessibility

  • Screen readers announce errors and which field they belong to.
  • Error summary (for long forms) links to the first error.
  • Focus moves logically and is never trapped.

Consistency

  • All forms reuse the same primitives (field, label, error).
  • Validation rules are shared where possible (Zod schemas, etc.).
  • Error colors and icons use the same tokens across the app.

Gotcha: validate on blur, not on change

Running validation on every keystroke (mode: "onChange") floods the user with errors before they finish typing. mode: "onBlur" waits until the field loses focus, which matches how people naturally fill forms. Reserve mode: "onSubmit" for short forms where early feedback isn't needed.

This is a shorter version of a guide I wrote at Frontkit Studio. The full post has the complete walkthrough.