Form Validation That Doesn't Frustrate Users
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.
Teams ship faster when form validation is predictable, accessible, and consistent across the whole product. That’s exactly what thefrontkit's SaaS Starter Kit and AI UX Kit are built for.
In this post, we’ll look at validation patterns that don’t frustrate users, and how you can implement them using the kits.
🎯 Principles of Non-Frustrating Validation
Great validation usually follows a few simple rules:
- 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. (See why accessibility should come first.)
- Be consistent: Every form behaves the same way across your app. Design tokens help keep error styling uniform.
The kits encode these principles into reusable components so you don’t reinvent them on every screen.
🔐 Example: Login Form with Helpful Validation (SaaS Starter Kit)
Here’s how a login form looks using the SaaS Starter Kit primitives and recipes:
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", // validate when focus leaves the field
})
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 Pattern Feels Good
- Errors are attached 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.
You don’t have to re-figure this structure for every form—the recipes give you a starting point that scales.
🧩 Pattern: Inline Errors + Error Summary
Longer forms benefit from both:
- Inline errors under each field, and
- An error summary at the top that links to the first problem.
You can implement this once and reuse it across your app.
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 SaaS Starter Kit includes a form system that already wires these patterns together so you can focus on your specific fields and rules.
🤖 AI-Specific Validation: Prompts, Files, and Feedback
In AI products, form-like interactions go beyond traditional forms (see building production-ready AI interfaces):
- Prompt textareas
- File uploads
- Feedback modals
The AI UX Kit ships these with smart defaults so you don’t have to hand-roll validation for every case.
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) {
// optional: show toast or inline error
return
}
sendToAI(value, attachedFiles)
}
return (
<PromptInput
value={prompt}
onChange={setPrompt}
onSubmit={handleSubmit}
attachedFiles={files}
onFileAttach={(newFiles) => {
// central place to validate file type/size/count
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.
- 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) {
// guard against empty rating, optionally show inline error
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}
/>
</>
)
}
✅ Form Validation Checklist
Use this checklist 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.
🚀 How thefrontkit Helps
Instead of hand-coding these patterns every time, you can:
-
Use SaaS Starter Kit for:
- Auth forms, settings forms, billing, team management
- Reusable
Form,Field,Input, and error components - A tokenized design system that keeps error styling consistent
-
Use AI UX Kit for:
- Prompt inputs, file uploads, feedback flows
- Error handling and empty states tailored for AI responses
- Accessible modals, toasts, and inline feedback components
Explore the kits:
- SaaS Starter Kit — Production-ready forms, dashboards, and settings.
- AI UX Kit — Prompt flows, streaming responses, and feedback loops.
With the right primitives in place, “good validation” stops being a debate on every screen—and becomes the default.


