Form Events
Handle form submissions, input changes, and form resets in React components.
Form Event Reference
| React Prop | TypeScript Type | Fires When | Notes |
|---|---|---|---|
onChange | React.ChangeEvent<T> | Value of an input, select, or textarea changes | In React, fires on every keystroke (not just blur) |
onInput | React.FormEvent<T> | Value of an input changes | Nearly identical to onChange in React -- prefer onChange |
onSubmit | React.FormEvent<HTMLFormElement> | Form is submitted (Enter key or submit button) | Call e.preventDefault() for client-side handling |
onReset | React.FormEvent<HTMLFormElement> | Form reset button is clicked | Rarely used -- most apps manage reset via state |
onInvalid | React.FormEvent<T> | Built-in validation fails on an input | Fires before the browser shows its validation tooltip |
Recipe
Quick-reference recipe card -- copy-paste ready.
// Controlled input with onChange
function ControlledInput() {
const [name, setName] = React.useState("");
return (
<input
value={name}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setName(e.target.value)}
placeholder="Enter name"
/>
);
}
// Form submission with preventDefault
function SimpleForm() {
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
console.log("Email:", formData.get("email"));
};
return (
<form onSubmit={handleSubmit}>
<input name="email" type="email" required />
<button type="submit">Submit</button>
</form>
);
}
// FormData extraction without controlled state
function UncontrolledForm() {
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const data = Object.fromEntries(new FormData(e.currentTarget));
console.log(data); // { email: "...", password: "..." }
};
return (
<form onSubmit={handleSubmit}>
<input name="email" type="email" />
<input name="password" type="password" />
<button type="submit">Sign in</button>
</form>
);
}When to reach for this: You need to handle user input in forms -- text fields, selects, checkboxes, file uploads, or form submissions with validation.
Working Example
"use client";
import { useState, useCallback } from "react";
type FormData = {
username: string;
email: string;
password: string;
role: string;
agree: boolean;
};
type FormErrors = Partial<Record<keyof FormData, string>>;
const INITIAL_STATE: FormData = {
username: "",
email: "",
password: "",
role: "",
agree: false,
};
function validate(data: FormData): FormErrors {
const errors: FormErrors = {};
if (data.username.length < 3) errors.username = "At least 3 characters";
if (!data.email.includes("@")) errors.email = "Invalid email address";
if (data.password.length < 8) errors.password = "At least 8 characters";
if (!data.role) errors.role = "Select a role";
if (!data.agree) errors.agree = "You must agree to the terms";
return errors;
}
export default function SignupForm() {
const [formData, setFormData] = useState<FormData>(INITIAL_STATE);
const [errors, setErrors] = useState<FormErrors>({});
const [submitted, setSubmitted] = useState(false);
const handleChange = useCallback(
(
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
) => {
const { name, type } = e.target;
const value =
type === "checkbox"
? (e.target as HTMLInputElement).checked
: e.target.value;
setFormData((prev) => ({ ...prev, [name]: value }));
// Clear error for this field on change
setErrors((prev) => ({ ...prev, [name]: undefined }));
},
[]
);
const handleSubmit = useCallback(
(e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const validationErrors = validate(formData);
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
console.log("Signup data:", formData);
setSubmitted(true);
},
[formData]
);
const handleReset = useCallback(() => {
setFormData(INITIAL_STATE);
setErrors({});
setSubmitted(false);
}, []);
const handleInvalid = useCallback(
(e: React.FormEvent<HTMLInputElement>) => {
e.preventDefault(); // Prevent browser tooltip
const name = e.currentTarget.name as keyof FormData;
setErrors((prev) => ({
...prev,
[name]: e.currentTarget.validationMessage,
}));
},
[]
);
if (submitted) {
return (
<div style={{ padding: 24 }}>
<p>Account created for {formData.username}.</p>
<button onClick={handleReset}>Sign up another</button>
</div>
);
}
return (
<form
onSubmit={handleSubmit}
onReset={handleReset}
noValidate
style={{
maxWidth: 400,
display: "flex",
flexDirection: "column",
gap: 16,
padding: 24,
}}
>
<h2 style={{ margin: 0 }}>Sign Up</h2>
<div>
<label htmlFor="username">Username</label>
<input
id="username"
name="username"
value={formData.username}
onChange={handleChange}
onInvalid={handleInvalid}
required
minLength={3}
style={{ display: "block", width: "100%", padding: "8px" }}
/>
{errors.username && (
<span style={{ color: "#dc2626", fontSize: 14 }}>
{errors.username}
</span>
)}
</div>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
name="email"
type="email"
value={formData.email}
onChange={handleChange}
onInvalid={handleInvalid}
required
style={{ display: "block", width: "100%", padding: "8px" }}
/>
{errors.email && (
<span style={{ color: "#dc2626", fontSize: 14 }}>
{errors.email}
</span>
)}
</div>
<div>
<label htmlFor="password">Password</label>
<input
id="password"
name="password"
type="password"
value={formData.password}
onChange={handleChange}
onInvalid={handleInvalid}
required
minLength={8}
style={{ display: "block", width: "100%", padding: "8px" }}
/>
{errors.password && (
<span style={{ color: "#dc2626", fontSize: 14 }}>
{errors.password}
</span>
)}
</div>
<div>
<label htmlFor="role">Role</label>
<select
id="role"
name="role"
value={formData.role}
onChange={handleChange}
style={{ display: "block", width: "100%", padding: "8px" }}
>
<option value="">Select a role...</option>
<option value="developer">Developer</option>
<option value="designer">Designer</option>
<option value="manager">Manager</option>
</select>
{errors.role && (
<span style={{ color: "#dc2626", fontSize: 14 }}>
{errors.role}
</span>
)}
</div>
<div>
<label>
<input
name="agree"
type="checkbox"
checked={formData.agree}
onChange={handleChange}
/>{" "}
I agree to the terms
</label>
{errors.agree && (
<span style={{ color: "#dc2626", fontSize: 14, display: "block" }}>
{errors.agree}
</span>
)}
</div>
<div style={{ display: "flex", gap: 8 }}>
<button type="submit" style={{ padding: "8px 16px" }}>
Create Account
</button>
<button type="reset" style={{ padding: "8px 16px" }}>
Reset
</button>
</div>
</form>
);
}What this demonstrates:
- Controlled inputs with a single
handleChangefor text, email, password, select, and checkbox - Custom validation running on submit with error display per field
- Using
onInvalidto intercept browser validation and show custom error messages - Using
onResetto clear form state back to initial values - TypeScript typing for form state, error state, and event handlers
- Handling checkbox
checkedvs text inputvaluein one handler with type narrowing
Deep Dive
How It Works
- React's
onChangefires on every keystroke for text inputs, which differs from the native DOMchangeevent (which fires on blur). This makes controlled inputs reactive and enables real-time validation. onSubmitfires when the form is submitted via Enter key (while focused in an input) or clicking atype="submit"button. Always calle.preventDefault()for client-side handling to prevent a full page reload.onInvalidfires whenform.reportValidity()orform.requestSubmit()triggers validation and an input fails its constraints (required,minLength,pattern, etc.). Usee.preventDefault()to suppress the browser tooltip and show custom UI.onResetfires when atype="reset"button is clicked. It does NOT reset React state -- you must handle state reset yourself.
Variations
Controlled vs uncontrolled inputs:
// Controlled: React state is the source of truth
function Controlled() {
const [value, setValue] = useState("");
return <input value={value} onChange={(e) => setValue(e.target.value)} />;
}
// Uncontrolled: DOM is the source of truth
function Uncontrolled() {
const inputRef = useRef<HTMLInputElement>(null);
const handleSubmit = () => {
console.log(inputRef.current?.value);
};
return <input ref={inputRef} defaultValue="" />;
}FormData extraction (modern pattern -- no controlled state needed):
function FormDataExample() {
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
// Get individual fields
const email = formData.get("email") as string;
// Convert to plain object
const data = Object.fromEntries(formData);
// Handle multiple values (e.g., multi-select, checkboxes with same name)
const tags = formData.getAll("tags") as string[];
console.log({ email, data, tags });
};
return (
<form onSubmit={handleSubmit}>
<input name="email" type="email" />
<select name="tags" multiple>
<option value="react">React</option>
<option value="typescript">TypeScript</option>
</select>
<button type="submit">Submit</button>
</form>
);
}React 19 form actions with useActionState:
"use client";
import { useActionState } from "react";
type State = { message: string; errors?: Record<string, string> };
async function submitSignup(
prevState: State,
formData: FormData
): Promise<State> {
const email = formData.get("email") as string;
const password = formData.get("password") as string;
if (!email.includes("@")) {
return { message: "", errors: { email: "Invalid email" } };
}
if (password.length < 8) {
return { message: "", errors: { password: "Too short" } };
}
// Simulate API call
await new Promise((r) => setTimeout(r, 1000));
return { message: `Welcome, ${email}!` };
}
export default function ActionForm() {
const [state, formAction, isPending] = useActionState(submitSignup, {
message: "",
});
return (
<form action={formAction}>
<input name="email" type="email" placeholder="Email" />
{state.errors?.email && <span>{state.errors.email}</span>}
<input name="password" type="password" placeholder="Password" />
{state.errors?.password && <span>{state.errors.password}</span>}
<button type="submit" disabled={isPending}>
{isPending ? "Signing up..." : "Sign up"}
</button>
{state.message && <p>{state.message}</p>}
</form>
);
}File input onChange:
function FileUpload() {
const [fileName, setFileName] = useState<string>("");
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setFileName(file.name);
console.log("Size:", file.size, "Type:", file.type);
}
};
return (
<div>
<input type="file" onChange={handleChange} accept="image/*" />
{fileName && <p>Selected: {fileName}</p>}
</div>
);
}Select onChange with typed values:
type Color = "red" | "green" | "blue";
function ColorPicker() {
const [color, setColor] = useState<Color>("red");
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
setColor(e.target.value as Color);
};
return (
<select value={color} onChange={handleChange}>
<option value="red">Red</option>
<option value="green">Green</option>
<option value="blue">Blue</option>
</select>
);
}TypeScript Notes
// React.ChangeEvent<T> -- for onChange handlers
// T must match the element: HTMLInputElement, HTMLSelectElement, HTMLTextAreaElement
const handleInput = (e: React.ChangeEvent<HTMLInputElement>) => {
e.target.value; // string -- the current input value
e.target.name; // string -- the name attribute
e.target.type; // string -- "text", "checkbox", "email", etc.
e.target.checked; // boolean -- only meaningful for checkboxes/radios
};
// React.FormEvent<HTMLFormElement> -- for onSubmit / onReset
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
e.currentTarget; // HTMLFormElement
new FormData(e.currentTarget); // FormData from the form
};
// Typing FormData values
const formData = new FormData(form);
const email = formData.get("email"); // FormDataEntryValue | null
const emailStr = formData.get("email") as string; // assert to string
const file = formData.get("avatar") as File; // assert to File
// Union handler for multiple input types
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>
) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
};
// React 19 useActionState typing
const [state, action, isPending] = useActionState<State, FormData>(
submitAction,
initialState
);
// Typing a form with Record for dynamic fields
type FormState = Record<string, string | boolean>;Gotchas
-
React's
onChangeis NOT the DOMchangeevent -- In native HTML,changefires on blur for text inputs. React'sonChangefires on every keystroke, behaving like the nativeinputevent. This surprises developers coming from vanilla JS. If you need blur-only behavior, useonBlurinstead. -
onResetdoes not reset React state -- Clicking atype="reset"button resets DOM form values to theirdefaultValuebut does NOT update React state. If you use controlled inputs, the state values immediately overwrite the DOM reset. Fix: HandleonResetexplicitly and reset your state to initial values. -
Checkbox
onChangegives youe.target.checked, note.target.value-- For checkboxes,e.target.valueis always the staticvalueattribute (defaults to"on"). The actual toggle state is ine.target.checked. Fix: Checke.target.type === "checkbox"and read.checkedfor boolean state. -
FormData.get()returnsFormDataEntryValue | null-- The return type isstring | File | null, not juststring. If you pass it directly to a function expectingstring, TypeScript will error. Fix: Assert the type:formData.get("email") as string. -
e.currentTargetisnullafter async operations -- Just like other Synthetic Events, accessinge.currentTargetinside anawaitorsetTimeoutgivesnull. Fix: Captureconst form = e.currentTarget;before any async work, then usenew FormData(form). -
File inputs cannot be controlled -- Setting
valueon a file input is not allowed for security reasons. File inputs are always uncontrolled. UseonChangeto read the selected file and store it in state, but do not try to set the input's value. -
React 19
useActionStaterequires a different mental model -- The action function receives(prevState, formData)and returns the new state. There is noe.preventDefault()-- the form uses theactionprop instead ofonSubmit. MixingonSubmitandactionon the same form leads to confusing behavior. Fix: Choose one pattern per form: eitheronSubmitwithpreventDefault, oractionwithuseActionState.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
React 19 action prop + useActionState | Server-validated forms, progressive enhancement, pending states | You need fine-grained client-side control over every keystroke |
| React Hook Form | Complex forms with many fields, deep validation, performance-sensitive | Simple forms with 1-3 fields |
| Zod + react-hook-form | Schema-based validation shared between client and server | Validation is trivial (just required) |
| Formik | Legacy projects already using it | New projects (prefer React Hook Form or native) |
Uncontrolled inputs + FormData | Simple forms where you only need values on submit | You need real-time validation or derived state from inputs |
| Server Actions (Next.js) | Form submission that runs server-side logic directly | Client-only apps with no server |
FAQs
How does React's onChange differ from the native DOM change event?
React's onChange fires on every keystroke for text inputs, behaving like the native input event. The native DOM change event only fires on blur. This is a common source of confusion for developers coming from vanilla JS.
What is the difference between controlled and uncontrolled inputs in React?
- Controlled: React state is the source of truth. You set
valueand update it viaonChange. - Uncontrolled: The DOM is the source of truth. You use
defaultValueand read via aref. - Use controlled for real-time validation or derived state; uncontrolled for simple submit-only forms.
How do you extract form data without using controlled state?
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const data = Object.fromEntries(new FormData(e.currentTarget));
console.log(data); // { email: "...", password: "..." }
};Why must you call e.preventDefault() in onSubmit?
Without e.preventDefault(), the browser performs its default form submission behavior, which causes a full page reload. For client-side handling in React, you always need to prevent this default.
Gotcha: Why does clicking a reset button not reset my controlled input values?
The type="reset" button resets DOM form values to their defaultValue, but it does NOT update React state. Since controlled inputs immediately overwrite the DOM with state values, the reset appears to do nothing. Handle onReset explicitly and reset your state to initial values.
How do you handle checkboxes in a single onChange handler alongside text inputs?
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, type } = e.target;
const value = type === "checkbox"
? e.target.checked
: e.target.value;
setFormData((prev) => ({ ...prev, [name]: value }));
};Check e.target.type === "checkbox" and read .checked instead of .value.
What is useActionState in React 19 and how does it differ from onSubmit?
useActionStateuses theactionprop on<form>instead ofonSubmit- The action function receives
(prevState, formData)and returns the new state - It provides an
isPendingboolean for loading states - Do not mix
onSubmitandactionon the same form
Gotcha: Why is e.currentTarget null after an await inside my submit handler?
React's synthetic events are recycled after the handler returns. Accessing e.currentTarget inside an await or setTimeout gives null. Capture it first: const form = e.currentTarget; then use new FormData(form).
How do you use onInvalid to show custom validation messages instead of browser tooltips?
<input
required
onInvalid={(e) => {
e.preventDefault(); // suppress browser tooltip
setError(e.currentTarget.validationMessage);
}}
/>Why can't you set the value of a file input in React?
File inputs cannot be controlled for security reasons. The browser forbids setting value on <input type="file">. They are always uncontrolled. Use onChange to read the selected file and store it in state.
What is the correct TypeScript type for FormData.get() and why does it need a type assertion?
FormData.get() returns FormDataEntryValue | null, which is string | File | null. If you pass it to a function expecting string, TypeScript will error. Assert the type: formData.get("email") as string.
How do you type a single onChange handler that works with input, select, and textarea elements?
const handleChange = (
e: React.ChangeEvent<
HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
>
) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
};Related
- Mouse Events -- Handling clicks, hovers, and mouse movement
- Keyboard Events -- Responding to key presses and keyboard shortcuts