Skip to main content
Frontend for Backend Engineers

Forms Without Tears

Ravinder··5 min read
FrontendReactTypeScriptFormsValidation
Share:
Forms Without Tears

Forms are where backend engineers' patience with frontend collapses. You know how to validate on the server, but the browser wants inline feedback. You want a clean payload, but you get nested field state. Forms in React have a reputation for being ceremonious — and they are, if you do them wrong.

The good news: there is a correct approach that makes forms feel as mechanical as writing a DTO and a validator on the server side. Here is the pattern.

Controlled vs Uncontrolled: The Core Choice

An uncontrolled input stores its value in the DOM, not in React state. You read it when you need it (usually on submit) via a ref:

function UncontrolledForm() {
  const inputRef = useRef<HTMLInputElement>(null);
 
  function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    const value = inputRef.current?.value;
    // send value to server
  }
 
  return (
    <form onSubmit={handleSubmit}>
      <input ref={inputRef} defaultValue="" />
      <button type="submit">Submit</button>
    </form>
  );
}

A controlled input stores its value in React state. Every keystroke updates state, which re-renders the input:

function ControlledInput() {
  const [value, setValue] = useState("");
 
  return (
    <input
      value={value}
      onChange={e => setValue(e.target.value)}
    />
  );
}

When to use which:

Controlled Uncontrolled
Inline validation on every keystroke Grab value only on submit
Dynamic field enabling/disabling File inputs (always uncontrolled)
Transforming input in real time Simple one-off forms
Complex multi-step forms

The Right Abstraction: React Hook Form + Zod

Rolling controlled forms by hand is the equivalent of parsing JSON with string manipulation. Use React Hook Form for form orchestration and Zod for schema validation — the same way you would use a DTO class with class-validator on the server.

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
 
// Define the schema — this is your DTO
const signupSchema = z.object({
  email: z.string().email("Enter a valid email"),
  password: z.string().min(8, "At least 8 characters"),
  confirmPassword: z.string(),
}).refine(
  data => data.password === data.confirmPassword,
  { message: "Passwords must match", path: ["confirmPassword"] }
);
 
type SignupForm = z.infer<typeof signupSchema>;
 
function SignupForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<SignupForm>({
    resolver: zodResolver(signupSchema),
  });
 
  async function onSubmit(data: SignupForm) {
    // data is fully typed and validated — send to server
    await fetch("/api/signup", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(data),
    });
  }
 
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <input type="email" {...register("email")} placeholder="Email" />
        {errors.email && <span>{errors.email.message}</span>}
      </div>
      <div>
        <input type="password" {...register("password")} placeholder="Password" />
        {errors.password && <span>{errors.password.message}</span>}
      </div>
      <div>
        <input type="password" {...register("confirmPassword")} placeholder="Confirm" />
        {errors.confirmPassword && <span>{errors.confirmPassword.message}</span>}
      </div>
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? "Creating account…" : "Sign Up"}
      </button>
    </form>
  );
}

React Hook Form uses uncontrolled inputs internally (via refs) for performance, but exposes a controlled API via register. You get the performance of uncontrolled with the ergonomics of controlled. The Zod schema gives you the same validation logic you can share with your backend.

Validation Timing Strategy

flowchart TD A[User types] --> B{Validation mode} B -->|onChange| C[Validate every keystroke] B -->|onBlur| D[Validate on field leave] B -->|onSubmit| E[Validate on submit only] C --> F[Immediate feedback — best for confirm fields] D --> G[Balanced — recommended default] E --> H[No inline feedback — avoid for long forms] F & G & H --> I[Show error message under field] I --> J[Submit sends to server] J --> K{Server response} K -->|422 / validation error| L[Map server errors to fields] K -->|success| M[Navigate or show success]

Setting server-side validation errors back into the form after submission:

async function onSubmit(data: SignupForm) {
  const res = await fetch("/api/signup", {
    method: "POST",
    body: JSON.stringify(data),
  });
 
  if (!res.ok) {
    const { errors: serverErrors } = await res.json();
    // Map server field errors back to the form
    for (const [field, message] of Object.entries(serverErrors)) {
      setError(field as keyof SignupForm, { message: message as string });
    }
    return;
  }
 
  router.push("/dashboard");
}

Handling File Uploads

File inputs are always uncontrolled. Use register and read via watch or the native value:

const fileSchema = z.object({
  avatar: z
    .custom<FileList>()
    .refine(files => files?.length === 1, "Please select a file")
    .refine(
      files => files?.[0]?.size < 2 * 1024 * 1024,
      "File must be under 2MB"
    ),
});
 
// In the form
<input type="file" accept="image/*" {...register("avatar")} />
{errors.avatar && <span>{errors.avatar.message}</span>}
 
// On submit
async function onSubmit(data) {
  const formData = new FormData();
  formData.append("avatar", data.avatar[0]);
  await fetch("/api/upload", { method: "POST", body: formData });
}

Multi-Step Forms

Multi-step forms are just one big schema split across components with shared form state:

const schema = z.object({
  step1: z.object({ name: z.string().min(1), email: z.string().email() }),
  step2: z.object({ plan: z.enum(["free", "pro"]) }),
});
 
function MultiStepForm() {
  const [step, setStep] = useState(1);
  const methods = useForm({ resolver: zodResolver(schema) });
 
  async function nextStep() {
    const isValid = await methods.trigger(`step${step}` as any);
    if (isValid) setStep(s => s + 1);
  }
 
  return (
    <FormProvider {...methods}>
      {step === 1 && <Step1 />}
      {step === 2 && <Step2 />}
      <button onClick={nextStep}>Next</button>
    </FormProvider>
  );
}

FormProvider is React Context specifically for forms — child components call useFormContext() to access the parent form without prop drilling.

Key Takeaways

  • Controlled inputs are right for inline validation and dynamic interactions; uncontrolled inputs (via refs) are right for file fields and simple read-on-submit forms.
  • React Hook Form + Zod replicates the DTO + validator pattern you know from server-side code, with shared schema logic that can span client and server.
  • Default to onBlur validation mode — it balances immediate feedback without firing on every keystroke.
  • Server validation errors should be mapped back to form fields after a failed submission, not shown as a generic toast.
  • File inputs are always uncontrolled — wrap them in FormData on submit rather than trying to store the FileList in React state.
  • Multi-step forms share a single form instance via FormProvider; trigger per-step validation before advancing to catch errors early.
Share: