#React#TypeScript

React Form Best Practices

Forms are frontend's most tedious work: validation, error messages, dynamic fields, and submission. This article covers complete React form practices: React Hook Form vs. Formik, Zod validation, controlled vs. uncontrolled inputs, Server Actions, and handling complex array and nested data.

Key Points

  • Topic Focus: React、TypeScript
  • Suitable for: indie hackers, full-stack engineers, AI application developers
  • Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
Author @admin·Published July 26, 2026·Updated July 26, 2026

1. Why Are Forms So Hard?

Every frontend engineer with two years of product work shares one consensus: no matter how polished the page implementation is, a broken form is an incident waiting to happen.

The difficulty of forms is not an "input + submit button." It is that they tie together nearly every troublesome concern in frontend development:

  • Validation: frontend validation, backend validation, and keeping them consistent;
  • Error messages: what is wrong with each field, what it should say, and when it should appear;
  • Dynamic fields: showing or hiding fields based on a value, adding and removing array entries, and linked behavior;
  • Submission: loading, disabled states, rollback after failure, duplicate submissions, and network interruptions;
  • Reuse: reusing the same logic across create, edit, and multi-step forms.

Worse still, React has no official form solution. A controlled pattern such as <input value={x} onChange={...} /> looks perfectly natural, but once there are more than 10 fields, every keystroke rerenders the entire form tree, and input lag becomes visibly obvious.

So the first principle of handling forms is not "which library should I use?" It is: choose the right tool and cut the mental burden of forms in half. Below, I will walk through real-world practice along the path of selection, validation, state, submission, errors, complex scenarios, and reuse.

2. Choosing: React Hook Form vs. Formik

These are the community mainstream, but their design philosophies are completely different.

Formik represents the controlled approach. Each field is bound through handleChange / values, with values stored in React state. Its strengths are a simple model and intuitive debugging; its weakness is one setState per keystroke. Once fields pile up, it becomes sluggish and requires manual memo usage or component splitting to optimize.

React Hook Form (RHF) takes the uncontrolled route. It uses register to register a ref on the native <input>, keeps data out of React state, and reads it all at once on submission. Its performance is several times better than Formik's, and its default reValidateMode provides a smoother experience.

tsx
// A minimal React Hook Form example
import { useForm } from "react-hook-form";

function LoginForm() {
  const { register, handleSubmit, formState: { errors } } = useForm();

  const onSubmit = (data) => console.log(data);

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("email", { required: "Email is required" })} />
      {errors.email && <p>{errors.email.message}</p>}
      <button type="submit">Sign in</button>
    </form>
  );
}

My recommendation is straightforward:

  • Use React Hook Form for all new projects: its ecosystem, performance, and TypeScript experience are all ahead;
  • Keep using Formik only when maintaining a legacy project or when the team already depends heavily on its ecosystem;
  • Stop hand-rolling form libraries with "controlled + useState." That is reinventing an incomplete wheel.

All examples below use RHF by default.

3. Validation: One Zod Schema Gives You Both Types and Validation

The biggest pitfall in form validation is "writing it separately on the frontend and backend." TypeScript types only solve the "shape" problem; they cannot validate email format, length limits, or required fields. Introduce Zod, and one schema produces both TypeScript types and runtime validation, then integrates seamlessly with RHF through @hookform/resolvers/zod.

tsx
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";

const schema = z.object({
  email: z.string().min(1, "Email is required").email("Invalid email format"),
  password: z.string().min(8, "Password must be at least 8 characters"),
  age: z.number({ invalid_type_error: "Enter a number" }).min(18, "You must be at least 18"),
});

type FormValues = z.infer<typeof schema>; // Type inferred automatically; no need to write an interface by hand

function RegisterForm() {
  const { register, handleSubmit, formState: { errors } } = useForm<FormValues>({
    resolver: zodResolver(schema),
  });

  const onSubmit = (data: FormValues) => {
    // data is type-safe and has passed validation
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input type="email" {...register("email")} />
      {errors.email && <span role="alert">{errors.email.message}</span>}
      <input type="password" {...register("password")} />
      {errors.password && <span role="alert">{errors.password.message}</span>}
      <input type="number" {...register("age", { valueAsNumber: true })} />
      {errors.age && <span role="alert">{errors.age.message}</span>}
      <button type="submit">Register</button>
    </form>
  );
}

Key points:

  1. The schema is the single source of truth. The frontend form, Server Action, and backend API share the same Zod schema. Change it once, and all three stay in sync;
  2. Use z.infer<typeof schema> to infer the type backward and eliminate the basic bug of mismatched types and validation;
  3. For numeric and date fields, remember to use valueAsNumber / valueAsDate; otherwise, you will get strings.

4. Controlled vs. Uncontrolled: Do Not Reach for setState First

A common beginner mistake is writing every field as const [email, setEmail] = useState("") just to access its value. Ten fields mean ten useState calls and a rendering explosion.

Uncontrolled (RHF's default approach): use register to obtain the native DOM ref, keep values in the DOM, and keep React out of the rerender on every keystroke. RHF reads everything together at submission time. This is the key to keeping large forms responsive.

Controlled (useState): suitable for scenarios that genuinely need real-time behavior driven by input, such as live search while typing or a progress bar based on input length. Even then, RHF provides watch and useWatch for on-demand subscriptions, so there is no need to fall back to useState:

tsx
const password = watch("password");
const strength = getPasswordStrength(password); // Subscribe on demand; rerender only when this field changes

Practical principles:

  • Default to uncontrolled and let RHF manage everything;
  • Use watch / useWatch for single-field real-time interactions, and do not overuse them;
  • Moving an entire form's state into a global store (Redux/Zustand) is almost always wrong: the form should live in the form component.

5. Submission: Server Actions + FormData Are the Right Way

In the Next.js App Router era, the best way to submit forms is Server Actions + useFormState / useFormStatus. It naturally supports progressive enhancement: even if JavaScript has not loaded, a native <form action> can still submit.

tsx
// app/actions/register.ts
"use server";
import { schema } from "@/lib/schemas";

export async function registerAction(prevState: any, formData: FormData) {
  const raw = Object.fromEntries(formData.entries());
  const parsed = schema.safeParse(raw);
  if (!parsed.success) {
    return { errors: parsed.error.flatten().fieldErrors };
  }
  try {
    await createUser(parsed.data);
    return { success: true };
  } catch (e) {
    return { error: "Registration failed. Please try again later." };
  }
}

// app/register/page.tsx
import { useFormState, useFormStatus } from "react-dom";
import { registerAction } from "@/app/actions/register";

function SubmitButton() {
  const { pending } = useFormStatus();
  return <button disabled={pending}>{pending ? "Submitting..." : "Register"}</button>;
}

export default function Page() {
  const [state, formAction] = useFormState(registerAction, null);
  return (
    <form action={formAction}>
      <input name="email" type="email" />
      {state?.errors?.email?.[0] && <p>{state.errors.email[0]}</p>}
      <SubmitButton />
    </form>
  );
}

For a standard SPA or API routes, use fetch inside handleSubmit. The logic is similar, but you must manage loading and error states yourself with isSubmitting:

tsx
const { formState: { isSubmitting }, setError } = useForm();

const onSubmit = async (data: FormValues) => {
  try {
    await fetch("/api/register", { method: "POST", body: JSON.stringify(data) });
  } catch (e) {
    setError("root", { message: "Network error. Please try again." }); // Form-level error
  }
};

Key points:

  • RHF's handleSubmit gives you the <button disabled={isSubmitting}> state directly, so you do not need to build it yourself;
  • Run safeParse again with the same Zod schema in the Server Action. Frontend validation is for experience, backend validation is for security, and both gates are essential.

6. Error Messages: Field-Level, Form-Level, and Accessibility

Error messages may look simple, but getting them wrong makes them both difficult to use and inaccessible.

Field-level errors: bind them below each input and show them immediately after focus or blur:

tsx
<input
  {...register("email")}
  aria-invalid={errors.email ? "true" : "false"}
/>
{errors.email && (
  <span role="alert" id="email-error">{errors.email.message}</span>
)}

Form-level errors: failures returned by the server after submission, such as "account already exists" or "verification code expired," should be placed consistently at the top of the form in a prominent alert area:

tsx
{errors.root && <div role="alert">{errors.root.message}</div>}

Accessibility essentials (which many people overlook):

  • Add aria-invalid to inputs so screen readers announce "invalid";
  • Use role="alert" for error messages to ensure they are announced immediately by screen readers;
  • Prefer aria-describedby to associate errors with inputs instead of relying only on visual placement.

No one pays attention to these details in a contract, but one test with a keyboard or accessibility tools exposes them immediately. This is the difference between professional frontend work and merely getting by.

7. Complex Forms: Arrays, Nesting, and Conditional Fields

Business forms are never flat. Orders have multiple line items, addresses have cascading province/city/district selections, and contracts have clauses that can be added or removed. All of these rely on RHF's useFieldArray and nested register.

tsx
import { useFieldArray } from "react-hook-form";

function OrderForm() {
  const { register, control, handleSubmit } = useForm({
    defaultValues: { items: [{ name: "", qty: 1 }] },
  });
  const { fields, append, remove } = useFieldArray({ control, name: "items" });

  return (
    <form onSubmit={handleSubmit(console.log)}>
      {fields.map((field, i) => (
        <div key={field.id}>
          <input {...register(`items.${i}.name` as const, { required: true })} />
          <input type="number" {...register(`items.${i}.qty` as const, { valueAsNumber: true })} />
          <button type="button" onClick={() => remove(i)}>Delete</button>
        </div>
      ))}
      <button type="button" onClick={() => append({ name: "", qty: 1 })}>Add item</button>
    </form>
  );
}

Several key points:

  1. useFieldArray must receive a stable key={field.id}. Never use the array index as the key, otherwise React will become confused when removing a middle item;
  2. Use a path string such as items.${i}.name for nested field names, and preserve the literal type in TypeScript with as const;
  3. Nested objects work the same way: use register("address.city") directly;
  4. Conditional fields: use watch for visibility and Zod's superRefine for linked validation. For a rule such as "if 'Other' is selected, a note is required," the cleanest place is the schema:
    tsx
    const schema = z.object({
      source: z.string(),
      other: z.string().optional(),
    }).superRefine((data, ctx) => {
      if (data.source === "other" && !data.other) {
        ctx.addIssue({ code: "custom", path: ["other"], message: "Please describe the source" });
      }
    });
    

The most important advice: do not put a giant 200-line form in a single file. Split it into sub-forms by business area (shipping information, payment information). Each sub-form can be an independent RHF child form or share the same form instance through useFormContext. Smaller files are easier to test and maintain.

8. Reuse: Hooks, Schemas, and Field Components

The final step is turning forms from "one-off" into "composable."

Encapsulate with a custom hook: extract form logic so the component only renders.

tsx
function useProfileForm(user?: User) {
  const form = useForm<ProfileValues>({
    resolver: zodResolver(profileSchema),
    defaultValues: user ?? { email: "", name: "", bio: "" },
  });
  const save = async (data: ProfileValues) => {
    await fetch("/api/profile", { method: "PUT", body: JSON.stringify(data) });
  };
  return { form, save };
}

Create and edit pages share one hook; only defaultValues differs. This is the classic CRUD pattern.

Shared schema: export the schema from lib/schemas/profile.ts, then import it in the frontend, Server Actions, and test cases so validation logic never drifts.

Field component library: encapsulate "input + label + error message + ARIA association" into <TextField /> and <SelectField /> to standardize the design language and accessibility behavior. When everyone on a team hand-writes wildly different inputs, building reusable field components is the dividing line for form engineering.

tsx
function TextField({ name, label, register, error }: TextFieldProps) {
  return (
    <label>
      {label}
      <input aria-invalid={!!error} aria-describedby={`${name}-error`} {...register(name)} />
      {error && <span id={`${name}-error`} role="alert">{error.message}</span>}
    </label>
  );
}

Summary

React forms run deep, but the main thread keeps them stable:

  1. Selection: React Hook Form is the first choice for new projects. It outperforms Formik in both performance and DX, so stop forcing forms through useState;
  2. Validation: a Zod schema is the single source of truth, combining types and runtime validation. Reuse it in Server Actions so frontend and backend never drift;
  3. State: default to uncontrolled, use watch / useWatch for field interactions, and do not put form data in a global store;
  4. Submission: App Router projects should prioritize Server Actions + FormData for natural progressive enhancement. Use isSubmitting for button state so loading and errors are never missed;
  5. Errors: use both field-level and form-level errors. aria-invalid / role="alert" are the baseline; accessibility is not optional;
  6. Complex scenarios: use useFieldArray for arrays and stable keys; use superRefine for conditional validation; always split giant forms into sub-forms;
  7. Reuse: the three-piece foundation of form engineering for teams is hooks for form logic, shared schemas, and a field component library.

Key takeaway: form complexity does not disappear; it only moves to a place that was not designed well. It either moves into a library's sound abstractions or into your bug list next month.

Choose the right tools, write good schemas, manage state well, and split components properly, and forms go from "frontend hell" to modules you can confidently deliver.

Related

Related Content