Form Events
Handle form submissions, input changes, and form resets in React components.
Search across all documentation pages
Handle form submissions, input changes, and form resets in React components.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
| 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 |
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.
"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:
handleChange for text, email, password, select, and checkboxonInvalid to intercept browser validation and show custom error messagesonReset to clear form state back to initial valueschecked vs text input value in one handler with type narrowingonChange fires on every keystroke for text inputs, which differs from the native DOM change event (which fires on blur). This makes controlled inputs reactive and enables real-time validation.onSubmit fires when the form is submitted via Enter key (while focused in an input) or clicking a type="submit" button. Always call e.preventDefault() for client-side handling to prevent a full page reload.onInvalid fires when form.reportValidity() or form.requestSubmit() triggers validation and an input fails its constraints (required, minLength, pattern, etc.). Use e.preventDefault() to suppress the browser tooltip and show custom UI.onReset fires when a type="reset" button is clicked. It does NOT reset React state -- you must handle state reset yourself.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>
);
}// 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>;React's onChange is NOT the DOM change event -- In native HTML, change fires on blur for text inputs. React's onChange fires on every keystroke, behaving like the native input event. This surprises developers coming from vanilla JS. If you need blur-only behavior, use onBlur instead.
onReset does not reset React state -- Clicking a type="reset" button resets DOM form values to their defaultValue but does NOT update React state. If you use controlled inputs, the state values immediately overwrite the DOM reset. Fix: Handle onReset explicitly and reset your state to initial values.
Checkbox onChange gives you e.target.checked, not e.target.value -- For checkboxes, e.target.value is always the static value attribute (defaults to "on"). The actual toggle state is in e.target.checked. Fix: Check e.target.type === "checkbox" and read .checked for boolean state.
FormData.get() returns FormDataEntryValue | null -- The return type is string | File | null, not just string. If you pass it directly to a function expecting string, TypeScript will error. Fix: Assert the type: formData.get("email") as string.
e.currentTarget is null after async operations -- Just like other Synthetic Events, accessing e.currentTarget inside an await or setTimeout gives null. Fix: Capture const form = e.currentTarget; before any async work, then use new FormData(form).
File inputs cannot be controlled -- Setting value on a file input is not allowed for security reasons. File inputs are always uncontrolled. Use onChange to read the selected file and store it in state, but do not try to set the input's value.
React 19 useActionState requires a different mental model -- The action function receives (prevState, formData) and returns the new state. There is no e.preventDefault() -- the form uses the action prop instead of onSubmit. Mixing onSubmit and action on the same form leads to confusing behavior. Fix: Choose one pattern per form: either onSubmit with preventDefault, or action with useActionState.
| 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 |
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.
value and update it via onChange.defaultValue and read via a ref.const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const data = Object.fromEntries(new FormData(e.currentTarget));
console.log(data); // { email: "...", password: "..." }
};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.
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.
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.
useActionState uses the action prop on <form> instead of onSubmit(prevState, formData) and returns the new stateisPending boolean for loading statesonSubmit and action on the same formReact'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).
<input
required
onInvalid={(e) => {
e.preventDefault(); // suppress browser tooltip
setError(e.currentTarget.validationMessage);
}}
/>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.
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.
const handleChange = (
e: React.ChangeEvent<
HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
>
) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
};Reviewed by Chris St. John·Last updated Jul 19, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥