ReactForm
Blog/TypeScript

React Form with TypeScript — Type-Safe Forms Complete Guide (2026)

TypeScript makes React forms safer and easier to refactor — but typing event handlers, validation errors, and generic form components has some non-obvious patterns. This guide covers everything from basic form state types to a fully typed, reusable form system.

ReactForm Team··11 min read

React forms without TypeScript work fine — until someone renames a field, adds a new required property, or mismatches the name attribute with the state key. TypeScript catches all of these at compile time instead of at runtime (or in a user bug report). This guide shows you the exact patterns needed to type forms correctly without fighting the compiler.

1. Why TypeScript with React forms

The most common bugs in untyped React forms are invisible until they happen in production:

With TypeScript, every field access is checked against your interface. The compiler tells you where you need to update code when the shape changes — before you push.

2. Typing form state with an interface

Start with an interface that describes the shape of the form data. This is the single source of truth — your state, validation function, and API payload all reference it.

tsx
interface RegistrationForm {
  firstName: string;
  lastName: string;
  email: string;
  age: number | "";      // empty string for unset numeric inputs
  role: "admin" | "editor" | "viewer";
  acceptTerms: boolean;
}

const INITIAL_STATE: RegistrationForm = {
  firstName: "",
  lastName: "",
  email: "",
  age: "",
  role: "viewer",
  acceptTerms: false,
};

// useState is automatically typed — no annotation needed
const [formData, setFormData] = useState(INITIAL_STATE);

Notice the age field is typed as number | "". Numeric inputs return an empty string before the user has typed anything, so you cannot type them as just number. This union is the standard approach.

3. Typing onChange and onSubmit handlers

The key type for any input's change event is React.ChangeEvent<HTMLInputElement>. Use HTMLTextAreaElement or HTMLSelectElement for those elements. A generic handler that covers all input types looks like this:

tsx
import { ChangeEvent, FormEvent, useState } from "react";

const handleChange = (
  e: ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>
) => {
  const { name, value, type } = e.target;

  setFormData((prev) => ({
    ...prev,
    [name]:
      type === "checkbox"
        ? (e.target as HTMLInputElement).checked
        : type === "number"
        ? value === "" ? "" : Number(value)
        : value,
  }));
};

const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
  e.preventDefault();
  // formData is fully typed here
  console.log(formData.firstName); // string — no any
};

The cast (e.target as HTMLInputElement).checked is needed because the union type does not expose checked directly — only HTMLInputElement has it.

Typed field names with keyof

If you want to strongly type the field name attribute — so a typo in the JSX causes a compile error — use keyof RegistrationForm:

tsx
// A typed input component that only accepts field names that exist in the form
interface TypedInputProps
  extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "name"> {
  name: keyof RegistrationForm;
}

function TypedInput({ name, ...props }: TypedInputProps) {
  return <input name={name} {...props} />;
}

// Now this fails to compile if "emai" doesn't exist in RegistrationForm:
// <TypedInput name="emai" />  ← TypeScript error ✓

4. Typing validation errors

Validation errors are a partial map of field names to error strings. Use Partial<Record<keyof T, string>> to keep them in sync with the form interface automatically:

tsx
type FormErrors<T> = Partial<Record<keyof T, string>>;

// For RegistrationForm, this is equivalent to:
// { firstName?: string; lastName?: string; email?: string; age?: string; ... }

const [errors, setErrors] = useState<FormErrors<RegistrationForm>>({});

const validate = (data: RegistrationForm): FormErrors<RegistrationForm> => {
  const errs: FormErrors<RegistrationForm> = {};

  if (!data.firstName.trim()) errs.firstName = "First name is required";
  if (!data.lastName.trim())  errs.lastName  = "Last name is required";

  if (!data.email.trim()) {
    errs.email = "Email is required";
  } else if (!/S+@S+.S+/.test(data.email)) {
    errs.email = "Enter a valid email address";
  }

  if (data.age === "" || data.age < 18) {
    errs.age = "You must be at least 18 years old";
  }

  if (!data.acceptTerms) {
    errs.acceptTerms = "You must accept the terms";
  }

  return errs;
};

Because FormErrors<T> is generic, you can reuse this type for any form — just pass the form's interface as the type parameter.

5. Reusable typed form components

Once you have an interface and a FormErrors type, you can build a generic field wrapper that shows errors automatically and never accepts invalid field names:

tsx
interface FieldProps<T> {
  label: string;
  name: keyof T;
  errors: FormErrors<T>;
  children: React.ReactNode;
}

function Field<T>({ label, name, errors, children }: FieldProps<T>) {
  const error = errors[name];
  return (
    <div>
      <label className="block text-sm font-medium text-gray-700 mb-1">{label}</label>
      {children}
      {error && <p className="text-red-500 text-xs mt-1">{error}</p>}
    </div>
  );
}

// Usage — TypeScript will error if "emai" is not a key of RegistrationForm
<Field<RegistrationForm> label="Email" name="email" errors={errors}>
  <input type="email" name="email" value={formData.email} onChange={handleChange} />
</Field>

6. Complete working example

Here is the full registration form in TypeScript with all the patterns assembled. The file is .tsx — drop it directly into a Next.js or Vite project.

tsx
"use client";
import { ChangeEvent, FormEvent, useState } from "react";

// ── Types ─────────────────────────────────────────────────────────────────────
interface SignupForm {
  firstName: string;
  lastName: string;
  email: string;
  password: string;
  role: "developer" | "designer" | "manager";
  acceptTerms: boolean;
}

type FormErrors = Partial<Record<keyof SignupForm, string>>;

// ── Validation ────────────────────────────────────────────────────────────────
function validate(data: SignupForm): FormErrors {
  const errs: FormErrors = {};
  if (!data.firstName.trim()) errs.firstName = "Required";
  if (!data.lastName.trim())  errs.lastName  = "Required";
  if (!data.email.trim())     errs.email     = "Required";
  else if (!/S+@S+.S+/.test(data.email)) errs.email = "Invalid email";
  if (!data.password)         errs.password  = "Required";
  else if (data.password.length < 8) errs.password = "Min 8 characters";
  if (!data.acceptTerms) errs.acceptTerms = "You must accept the terms";
  return errs;
}

// ── Helpers ───────────────────────────────────────────────────────────────────
const inputCls = (error?: string) =>
  `w-full border rounded-xl px-3 py-2.5 text-sm outline-none transition ${
    error
      ? "border-red-400 focus:ring-1 focus:ring-red-400"
      : "border-gray-200 focus:border-teal-500 focus:ring-1 focus:ring-teal-500"
  }`;

// ── Component ─────────────────────────────────────────────────────────────────
const INITIAL: SignupForm = {
  firstName: "",
  lastName: "",
  email: "",
  password: "",
  role: "developer",
  acceptTerms: false,
};

export default function SignupForm() {
  const [data, setData]     = useState<SignupForm>(INITIAL);
  const [errors, setErrors] = useState<FormErrors>({});
  const [done, setDone]     = useState(false);

  const handleChange = (
    e: ChangeEvent<HTMLInputElement | HTMLSelectElement>
  ) => {
    const { name, value, type } = e.target;
    const checked = (e.target as HTMLInputElement).checked;
    setData((p) => ({
      ...p,
      [name]: type === "checkbox" ? checked : value,
    }));
    if (errors[name as keyof SignupForm]) {
      setErrors((p) => ({ ...p, [name]: undefined }));
    }
  };

  const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const errs = validate(data);
    if (Object.keys(errs).length > 0) { setErrors(errs); return; }
    console.log("Submitted:", data);
    setDone(true);
  };

  if (done) {
    return (
      <div className="max-w-lg mx-auto p-10 text-center flex flex-col items-center gap-4">
        <div className="w-14 h-14 bg-teal-500 rounded-full flex items-center justify-center">
          <svg className="w-7 h-7 text-white" fill="none" stroke="currentColor" strokeWidth={2.5} viewBox="0 0 24 24">
            <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
          </svg>
        </div>
        <h2 className="text-xl font-bold text-gray-800">Welcome, {data.firstName}!</h2>
        <p className="text-sm text-gray-500">Your account has been created.</p>
      </div>
    );
  }

  return (
    <form
      onSubmit={handleSubmit}
      className="max-w-lg mx-auto bg-white border border-gray-100 rounded-2xl shadow-sm p-8 flex flex-col gap-5"
    >
      <h1 className="text-xl font-bold text-gray-800">Create your account</h1>

      <div className="grid grid-cols-2 gap-4">
        {(["firstName", "lastName"] as const).map((field) => (
          <div key={field}>
            <label className="block text-sm font-medium text-gray-700 mb-1 capitalize">
              {field === "firstName" ? "First name" : "Last name"}
            </label>
            <input
              type="text"
              name={field}
              value={data[field]}
              onChange={handleChange}
              placeholder={field === "firstName" ? "Jane" : "Smith"}
              className={inputCls(errors[field])}
            />
            {errors[field] && <p className="text-red-500 text-xs mt-1">{errors[field]}</p>}
          </div>
        ))}
      </div>

      <div>
        <label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
        <input
          type="email"
          name="email"
          value={data.email}
          onChange={handleChange}
          placeholder="jane@example.com"
          className={inputCls(errors.email)}
        />
        {errors.email && <p className="text-red-500 text-xs mt-1">{errors.email}</p>}
      </div>

      <div>
        <label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
        <input
          type="password"
          name="password"
          value={data.password}
          onChange={handleChange}
          placeholder="Min 8 characters"
          className={inputCls(errors.password)}
        />
        {errors.password && <p className="text-red-500 text-xs mt-1">{errors.password}</p>}
      </div>

      <div>
        <label className="block text-sm font-medium text-gray-700 mb-1">Role</label>
        <select
          name="role"
          value={data.role}
          onChange={handleChange}
          className={inputCls(errors.role)}
        >
          <option value="developer">Developer</option>
          <option value="designer">Designer</option>
          <option value="manager">Manager</option>
        </select>
      </div>

      <div className="flex items-start gap-3">
        <input
          type="checkbox"
          name="acceptTerms"
          id="acceptTerms"
          checked={data.acceptTerms}
          onChange={handleChange}
          className="mt-0.5 accent-teal-600"
        />
        <label htmlFor="acceptTerms" className="text-sm text-gray-600">
          I agree to the <a href="/terms" className="text-teal-600 underline">terms of service</a> and{" "}
          <a href="/privacy" className="text-teal-600 underline">privacy policy</a>
        </label>
      </div>
      {errors.acceptTerms && <p className="text-red-500 text-xs -mt-3">{errors.acceptTerms}</p>}

      <button
        type="submit"
        className="bg-teal-600 text-white font-semibold py-2.5 px-5 rounded-xl hover:bg-teal-700 transition"
      >
        Create account
      </button>
    </form>
  );
}

What TypeScript catches that JavaScript misses

7. Skip the boilerplate entirely

If you need a production form fast — without writing TypeScript interfaces, validation functions, and error display logic from scratch — ReactForm.co lets you build and publish forms visually in minutes. The exported React code uses typed props and clean JSX you can paste directly into a TypeScript project.

You can also publish the form with a shareable link and collect responses without writing any backend code. Free plan included, no credit card required.

Build forms visually — export typed React code

Design your form in the visual builder, then export it as clean React JSX ready to paste into your TypeScript project. Free plan, no credit card required.

Open the Form Builder →