Post 11

Forms That Don't Fight the User

Validation that fires on every keystroke, error messages that scold, a submit button that does nothing visible — forms are where products lose people. Here is how I build ones that get out of the way, with React Hook Form, Zod, and Server Actions.

Feb 20, 2026/11 min readFrontend
ShareY
Forms That Don't Fight the User

Forms are the highest-stakes interface a product has. Every field is a chance for the user to give up. And yet forms get built fast, tested on the happy path, and shipped with validation behaviour that actively antagonises people.

Here is the model I use.

One schema, both sides

The validation rules live in a Zod schema that runs on the client for instant feedback and again in the Server Action, because client validation is a UX affordance, not a security boundary. Same schema, imported in both places. No drift.

app/contact/schema.ts
1import { z } from "zod";2 3export const contactSchema = z.object({4  name: z.string().min(1, "Please enter your name"),5  email: z.string().email("That doesn’t look like an email address"),6  company: z.string().optional(),7  message: z.string().min(10, "A little more detail helps me reply well"),8});9 10export type ContactInput = z.infer<typeof contactSchema>;
Tip

Notice the messages. They say what to do, not what is wrong. “That doesn’t look like an email address” beats “invalid input.” “A little more detail helps me reply well” beats “String must contain at least 10 character(s)”.

Validate on the right event, at the right time

The default in most form libraries is to validate on change. That means the moment someone types the first letter of their email, they get “invalid email address” in red. That is hostile. The rule that respects the user:

  • Before first submit: validate a field on blur, only after the user has left it. Do not validate a field they have not touched.
  • After first submit: switch that field to validate on change, so they get live feedback while fixing what you flagged.
  • Never show an error for a field the user has not reached yet.
app/contact/contact-form.tsx
1"use client";2 3import { useForm } from "react-hook-form";4import { zodResolver } from "@hookform/resolvers/zod";5import { contactSchema, type ContactInput } from "./schema";6import { sendMessage } from "./actions";7 8export function ContactForm() {9  const {10    register, handleSubmit, setError, setFocus,11    formState: { errors, isSubmitting, isSubmitSuccessful },12  } = useForm<ContactInput>({13    resolver: zodResolver(contactSchema),14    mode: "onTouched",        // validate on blur, pre-submit15    reValidateMode: "onChange", // live feedback once flagged16  });17 18  async function onSubmit(values: ContactInput) {19    const res = await sendMessage(values);20    if (!res.ok) {21      setError("root", { message: res.error });22      return;23    }24  }25 26  if (isSubmitSuccessful) return <SuccessPanel />;27 28  return (29    <form onSubmit={handleSubmit(onSubmit)} noValidate>30      <Field label="Name" error={errors.name?.message}>31        <input {...register("name")} aria-invalid={!!errors.name} />32      </Field>33      <Field label="Email" error={errors.email?.message}>34        <input type="email" {...register("email")}35               aria-invalid={!!errors.email} />36      </Field>37      <Field label="Message" error={errors.message?.message}>38        <textarea rows={6} {...register("message")}39                  aria-invalid={!!errors.message} />40      </Field>41 42      {errors.root && <p role="alert" className="error">{errors.root.message}</p>}43 44      <button disabled={isSubmitting} aria-busy={isSubmitting}>45        {isSubmitting ? "Sending…" : "Send message"}46      </button>47    </form>48  );49}

The Server Action re-validates and does the work

app/contact/actions.ts
1"use server";2 3import { contactSchema } from "./schema";4import { rateLimit } from "@/lib/rate-limit";5import { sendEmail } from "@/lib/email";6 7export async function sendMessage(raw: unknown) {8  const parsed = contactSchema.safeParse(raw);9  if (!parsed.success) {10    return { ok: false as const, error: "Some fields need another look." };11  }12 13  const ok = await rateLimit("contact", { max: 3, windowSec: 3600 });14  if (!ok) {15    return { ok: false as const, error: "Too many messages — try again later." };16  }17 18  await sendEmail({19    to: process.env.CONTACT_INBOX!,20    replyTo: parsed.data.email,21    subject: `New message from ${parsed.data.name}`,22    text: parsed.data.message,23  });24 25  return { ok: true as const };26}

The submit button must show it heard you

When the user clicks submit, three things need to happen within one frame: the button enters a pending state, it becomes non-interactive, and — if validation failed — focus moves to the first invalid field.

focus the first error
1// after handleSubmit rejects, RHF gives you errors in field order2const firstError = Object.keys(errors)[0] as keyof ContactInput | undefined;3if (firstError) {4  setFocus(firstError);5  document.getElementById(firstError)?.scrollIntoView({6    block: "center", behavior: "smooth",7  });8}
Watch out

A submit that silently does nothing is the single most common way to make a user click five more times and then leave. If nothing visibly changes on click, the form is broken as far as they are concerned.

Design the failure states

StateWhat the user should see
Field errorMessage below the field, red text and an icon, border changes, aria-invalid, aria-describedby
Server rejected the submitA summary at the top, focus moved to it, the form still filled in
Network failed“Couldn’t reach the server — your message is still here, try again”
SuccessReplace the form with a confirmation — never just clear the fields silently

That last row matters. If a form succeeds and just empties itself, a good number of users assume it broke and submit again.

Autosave the long ones

If a form takes more than a minute to fill — an application, an onboarding flow — persist the draft on change and restore it on load.

hooks/use-form-persist.ts
1import { useEffect } from "react";2import type { UseFormWatch, UseFormReset } from "react-hook-form";3 4export function useFormPersist<T extends Record<string, unknown>>(5  key: string, watch: UseFormWatch<T>, reset: UseFormReset<T>,6) {7  useEffect(() => {8    try {9      const saved = localStorage.getItem(key);10      if (saved) reset(JSON.parse(saved));11    } catch {}12  }, [key, reset]);13 14  useEffect(() => {15    const sub = watch((values) => {16      try { localStorage.setItem(key, JSON.stringify(values)); } catch {}17    });18    return () => sub.unsubscribe();19  }, [key, watch]);20}
A form that is accessible is almost always a form that is pleasant for everyone. The constraints push you toward clarity.

Found this useful? Pass it on.

All posts

Want a custom write-up for your team? Get in touch.

Building something like this?

If a post here maps to a problem on your roadmap, that's usually a good sign we should talk.