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.
In this guide
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:
- Accessing
formData.emaiinstead offormData.email— silent undefined - Sending the wrong field name to an API because a field was renamed in state but not in the payload
- Passing a string where a number is expected in a form with mixed field types
- Forgetting to handle a new required field after adding it to the state interface
- Validation functions that reference fields that no longer exist
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.
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:
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:
// 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:
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:
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.
"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
- Accessing
data.role === "owner"fails —"owner"is not in the union type - Passing
name="emai"to a typed input component fails at compile time - Forgetting to handle
acceptTermsin the validate function causes a type gap you can catch with exhaustive checks - Adding a new field to the interface automatically propagates to the INITIAL state — TypeScript errors if you forget
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.
Related articles
React Form Validation — The Complete Guide Without Libraries
Required fields, email format, length limits, and real-time feedback.
React Multi-Step Form — Build a Wizard with Progress Bar
Split a long form into steps with per-step validation and a progress bar.
How to Build a React Form — The Complete Guide (2026)
From useState to validation, conditional logic, and publishing live forms.
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 →
