React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

form-eventsonchangeonsubmitform-actionsreact-19typescript

Form Events

Handle form submissions, input changes, and form resets in React components.

Form Event Reference

React PropTypeScript TypeFires WhenNotes
onChangeReact.ChangeEvent<T>Value of an input, select, or textarea changesIn React, fires on every keystroke (not just blur)
onInputReact.FormEvent<T>Value of an input changesNearly identical to onChange in React -- prefer onChange
onSubmitReact.FormEvent<HTMLFormElement>Form is submitted (Enter key or submit button)Call e.preventDefault() for client-side handling
onResetReact.FormEvent<HTMLFormElement>Form reset button is clickedRarely used -- most apps manage reset via state
onInvalidReact.FormEvent<T>Built-in validation fails on an inputFires 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 handleChange for text, email, password, select, and checkbox
  • Custom validation running on submit with error display per field
  • Using onInvalid to intercept browser validation and show custom error messages
  • Using onReset to clear form state back to initial values
  • TypeScript typing for form state, error state, and event handlers
  • Handling checkbox checked vs text input value in one handler with type narrowing

Deep Dive

How It Works

  • React's onChange 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.

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 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.

Alternatives

AlternativeUse WhenDon't Use When
React 19 action prop + useActionStateServer-validated forms, progressive enhancement, pending statesYou need fine-grained client-side control over every keystroke
React Hook FormComplex forms with many fields, deep validation, performance-sensitiveSimple forms with 1-3 fields
Zod + react-hook-formSchema-based validation shared between client and serverValidation is trivial (just required)
FormikLegacy projects already using itNew projects (prefer React Hook Form or native)
Uncontrolled inputs + FormDataSimple forms where you only need values on submitYou need real-time validation or derived state from inputs
Server Actions (Next.js)Form submission that runs server-side logic directlyClient-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 value and update it via onChange.
  • Uncontrolled: The DOM is the source of truth. You use defaultValue and read via a ref.
  • 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?
  • useActionState uses the action prop on <form> instead of onSubmit
  • The action function receives (prevState, formData) and returns the new state
  • It provides an isPending boolean for loading states
  • Do not mix onSubmit and action on 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 }));
};