A typed React form is easiest to maintain when one type describes the submitted values, another describes possible field errors, and every input is controlled by that state. The complete example below includes text, email, select, textarea, and checkbox inputs; validates them before submitting; prevents duplicate submissions; and exposes loading, success, and failure states.
What You Will Build
The example is a contact form with these fields:
nameandemail, both requiredtopic, selected from a fixed set of optionsmessage, with a minimum length ruleacceptTerms, a required checkbox
It demonstrates the full workflow: typed initial values flow into controlled inputs, a validation function returns field-level errors, and an asynchronous submit handler changes the visible form status. Replace the placeholder request with your own endpoint when the UI is ready.
If you need the wider context for combining the two technologies, see the TypeScript + React in 2026: The Complete Setup Guide.
Requirements and Version Assumptions
This component assumes an existing React project configured to compile TypeScript and JSX/TSX files. Put it in a file such as src/components/ContactForm.tsx, then render it from an existing page or application component. The example uses React hooks and standard browser form APIs; it does not require a form package.
React, TypeScript, the project build tool, strictness settings, and lint rules vary between applications. Confirm the exact package and runtime versions in your project before publishing or adopting a version-specific implementation. In particular, follow your project’s existing conventions for module imports, API clients, and environment variables. For project setup questions, start with the React and TypeScript setup guide.
Define the Form Data and Component State
Start by modeling the values the form can submit. The field names in ContactFormData intentionally match each input’s name attribute. That agreement makes a shared change handler possible and prevents a common source of mapping mistakes.
type Topic = "general" | "support" | "partnership";
type ContactFormData = {
name: string;
email: string;
topic: Topic;
message: string;
acceptTerms: boolean;
};
type FormErrors = Partial<Record<keyof ContactFormData, string>>;
type SubmitStatus =
| { state: "idle" }
| { state: "submitting" }
| { state: "success"; message: string }
| { state: "error"; message: string };
const initialFormData: ContactFormData = {
name: "",
email: "",
topic: "general",
message: "",
acceptTerms: false,
};Partial<Record<keyof ContactFormData, string>> means an error object may have any form-field key, but every included value must be a string. For example, { email: "Enter a valid email address." } is valid, while a typo such as emali is caught by TypeScript.
Required and optional fields can both be represented in the submitted data type. The important difference is their default value and validation rule. A controlled optional text field normally starts as "", not undefined; an optional checkbox normally starts as false. Use an optional property only when the value genuinely may be omitted from the object sent to the server.
Build the Controlled Form Inputs
A controlled input receives its displayed value from React state and writes changes back through an event handler. Text inputs, email inputs, selects, and textareas use value. Checkboxes use checked. Do not use value to control whether a checkbox is selected.
For a small form with fields of different kinds, separate handlers are clear and type-safe. The text handler only accepts fields whose values are strings, while the checkbox handler only accepts the boolean field:
const handleTextChange = (
event: React.ChangeEvent<
HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
>
) => {
const { name, value } = event.target;
const field = name as Exclude<keyof ContactFormData, "acceptTerms">;
setFormData((current) => ({
...current,
[field]: value,
}));
};
const handleTermsChange = (
event: React.ChangeEvent<HTMLInputElement>
) => {
setFormData((current) => ({
...current,
acceptTerms: event.target.checked,
}));
};The type assertion is safe here because the component owns the markup and the string-valued input names are known. As the form grows, an explicit handler per unusual control can be easier to read than a highly generic handler.
Every control needs a label with an htmlFor value matching the control’s id. When an error exists, connect it with aria-describedby and mark the invalid field with aria-invalid. This gives assistive technology a meaningful relationship between a field and its feedback.
Add Client-Side Validation
Keep validation in a pure function: it receives typed values and returns typed errors. A pure function is straightforward to test and can be reused when validating on blur or before an API call.
function validateForm(values: ContactFormData): FormErrors {
const errors: FormErrors = {};
if (!values.name.trim()) {
errors.name = "Enter your name.";
}
if (!values.email.trim()) {
errors.email = "Enter your email address.";
} else if (!/^\S+@\S+\.\S+$/.test(values.email)) {
errors.email = "Enter a valid email address.";
}
if (!values.message.trim()) {
errors.message = "Enter a message.";
} else if (values.message.trim().length < 20) {
errors.message = "Write at least 20 characters.";
}
if (!values.acceptTerms) {
errors.acceptTerms = "You must accept the terms before submitting.";
}
return errors;
}This email expression is deliberately basic. It helps users spot obvious mistakes, but it does not prove that an address exists or that a person owns it. Likewise, browser validation and client-side JavaScript can be bypassed. The server must validate every value again, enforce authorization, and return safe error responses.
For many forms, validate all fields on submit first. After a field has produced an error, clear or refresh that field’s error as the user edits it. This avoids showing a page of red messages before the user has tried to submit, while still giving timely correction after an error. Blur validation can also work well for longer forms, but should not replace the final submit-time validation.
Handle Submission and Form Status
The submit handler must call preventDefault() so the browser does not navigate away or reload the page. It then validates the current state, stops if there are errors, and sets a submitting status before starting asynchronous work.
Disable the submit button while the request is active. The early if guard is useful as well: disabled controls are a UI protection, while the guard protects the handler if it is triggered another way.
A real request belongs after validation and inside try/catch. Keep the response parsing and error policy close to that request. For example, an endpoint may return field-specific errors that can be mapped into FormErrors, or it may return only a general failure message. Never place secrets or privileged credentials in this component; browser code is visible to its users.
Complete Working Example
Copy this component into a .tsx file. Its simulated request lets the status flow be demonstrated without assuming an API endpoint. Replace submitContactForm with your typed API client when integrating a backend.
import { FormEvent, useState } from "react";
type Topic = "general" | "support" | "partnership";
type ContactFormData = {
name: string;
email: string;
topic: Topic;
message: string;
acceptTerms: boolean;
};
type FormErrors = Partial<Record<keyof ContactFormData, string>>;
type SubmitStatus =
| { state: "idle" }
| { state: "submitting" }
| { state: "success"; message: string }
| { state: "error"; message: string };
const initialFormData: ContactFormData = {
name: "",
email: "",
topic: "general",
message: "",
acceptTerms: false,
};
function validateForm(values: ContactFormData): FormErrors {
const errors: FormErrors = {};
if (!values.name.trim()) errors.name = "Enter your name.";
if (!values.email.trim()) {
errors.email = "Enter your email address.";
} else if (!/^\S+@\S+\.\S+$/.test(values.email)) {
errors.email = "Enter a valid email address.";
}
if (!values.message.trim()) {
errors.message = "Enter a message.";
} else if (values.message.trim().length < 20) {
errors.message = "Write at least 20 characters.";
}
if (!values.acceptTerms) {
errors.acceptTerms = "You must accept the terms before submitting.";
}
return errors;
}
async function submitContactForm(values: ContactFormData): Promise<void> {
// Replace this placeholder with a request to your own API.
await new Promise((resolve) => window.setTimeout(resolve, 700));
console.log("Form data ready to submit:", values);
}
export default function ContactForm() {
const [formData, setFormData] = useState<ContactFormData>(initialFormData);
const [errors, setErrors] = useState<FormErrors>({});
const [status, setStatus] = useState<SubmitStatus>({ state: "idle" });
const handleTextChange = (
event: React.ChangeEvent<
HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
>
) => {
const { name, value } = event.target;
const field = name as Exclude<keyof ContactFormData, "acceptTerms">;
setFormData((current) => ({ ...current, [field]: value }));
setErrors((current) => ({ ...current, [field]: undefined }));
};
const handleTermsChange = (
event: React.ChangeEvent<HTMLInputElement>
) => {
const checked = event.target.checked;
setFormData((current) => ({ ...current, acceptTerms: checked }));
setErrors((current) => ({ ...current, acceptTerms: undefined }));
};
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (status.state === "submitting") return;
const nextErrors = validateForm(formData);
setErrors(nextErrors);
if (Object.keys(nextErrors).length > 0) {
setStatus({ state: "error", message: "Check the highlighted fields." });
return;
}
setStatus({ state: "submitting" });
try {
await submitContactForm(formData);
setFormData(initialFormData);
setErrors({});
setStatus({ state: "success", message: "Your message was sent." });
} catch {
setStatus({
state: "error",
message: "Your message could not be sent. Please try again.",
});
}
};
const isSubmitting = status.state === "submitting";
return (
<form noValidate onSubmit={handleSubmit}>
<div>
<label htmlFor="name">Name</label>
<input
id="name"
name="name"
type="text"
value={formData.name}
onChange={handleTextChange}
aria-invalid={Boolean(errors.name)}
aria-describedby={errors.name ? "name-error" : undefined}
/>
{errors.name && <p id="name-error" role="alert">{errors.name}</p>}
</div>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
name="email"
type="email"
value={formData.email}
onChange={handleTextChange}
aria-invalid={Boolean(errors.email)}
aria-describedby={errors.email ? "email-error" : undefined}
/>
{errors.email && <p id="email-error" role="alert">{errors.email}</p>}
</div>
<div>
<label htmlFor="topic">Topic</label>
<select
id="topic"
name="topic"
value={formData.topic}
onChange={handleTextChange}
>
<option value="general">General question</option>
<option value="support">Support</option>
<option value="partnership">Partnership</option>
</select>
</div>
<div>
<label htmlFor="message">Message</label>
<textarea
id="message"
name="message"
value={formData.message}
onChange={handleTextChange}
aria-invalid={Boolean(errors.message)}
aria-describedby={errors.message ? "message-error" : undefined}
/>
{errors.message && (
<p id="message-error" role="alert">{errors.message}</p>
)}
</div>
<div>
<input
id="acceptTerms"
name="acceptTerms"
type="checkbox"
checked={formData.acceptTerms}
onChange={handleTermsChange}
aria-invalid={Boolean(errors.acceptTerms)}
aria-describedby={errors.acceptTerms ? "terms-error" : undefined}
/>
<label htmlFor="acceptTerms">I accept the terms.</label>
{errors.acceptTerms && (
<p id="terms-error" role="alert">{errors.acceptTerms}</p>
)}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Sending…" : "Send message"}
</button>
<div aria-live="polite" aria-atomic="true">
{status.state === "success" && <p>{status.message}</p>}
{status.state === "error" && <p role="alert">{status.message}</p>}
</div>
</form>
);
}Change ContactFormData, initialFormData, the validation function, and the JSX together whenever you add a field. For a new required company text field, add company: string to the type, company: "" to the initial object, a validation rule, and an input whose name="company". Keeping these parts aligned is the core maintenance rule for this pattern.
Accessibility, Error Handling, and Security Considerations
Visible labels are more reliable than placeholders because they remain available after a user enters a value. Associate error text with the control using aria-describedby, and use aria-invalid only when the field has an error. The example uses an aria-live region for changing submission status so that success and failure updates can be announced without moving focus.
For a longer form, consider moving focus to an error summary or the first invalid field after a failed submit. Do so deliberately: unexpected focus changes while a user is typing can be disruptive. A summary can link to invalid fields, while each field still retains its local error message.
Client-side validation improves feedback; it is not a security boundary. Validate data again on the server, check authentication and authorization there, apply rate limits or abuse controls where appropriate, and treat all submitted strings as untrusted. Do not echo passwords, tokens, personal data, raw server details, or request bodies in user-facing errors. The demo logs values only to illustrate where submission occurs; remove such logging for sensitive forms.
When calling an API, define request and response types rather than casting unknown JSON directly to trusted objects. Handle non-success responses, network failures, and server-provided field errors explicitly. The UI should offer a useful, safe message even when the underlying reason is not suitable for display.
Common Problems and Adaptations
Why does React warn about an uncontrolled input becoming controlled?
This warning usually means a field initially receives undefined or null, then later receives a string or boolean. Initialize every controlled text-like field with "" and every controlled checkbox with false. If API data may be missing, normalize it before setting form state, for example name: apiData.name ?? "".
Why is a field not updating?
Check that the input name exactly matches its state key. A name such as emailAddress will not correctly update a state object expecting email. Also verify that selects and textareas use value, while checkboxes use checked. If you want a checkbox styled as a switch, the same boolean controlled-input rule applies; this React toggle switch example shows the related interaction pattern.
How do you reset a typed React form after success?
Set the state back to the typed initial object after the request succeeds: setFormData(initialFormData). Reset errors at the same time. Do not reset before the request resolves, or users lose their entered data if the request fails. If the initial object is nested or could be mutated elsewhere, create it with a function that returns a fresh object.
How do you connect this form to an API safely?
Replace the placeholder function with a request that accepts ContactFormData. Check response.ok before treating a response as successful, parse only the expected response shape, and map known server field errors to FormErrors. Keep transport concerns in a small API function and keep UI transitions in the component. This separation makes it easier to test and change either part.
When is a form library useful?
Custom state is a good fit for a compact form with straightforward validation and no special widgets. Consider a form library when a form has many nested fields, dynamic field arrays, complex validation schemas, multi-step behavior, extensive server-error mapping, or repeated form patterns across an application. A library should reduce repeated complexity, not obscure a form that is already easy to understand.
Next Step
Once this local workflow is clear, continue with the TypeScript + React setup guide to review the surrounding project configuration. Then adapt submitContactForm to your application’s typed API layer and add server-side validation before accepting real submissions.
