Skip to content

React Tutorial Forms and Validation

Building Reusable Form Components in React

Lesson 79 of 156 2 min read By Deepak Paswan Updated Oct 10, 2026

Runnable lesson · press Try it on the examples to edit and run them here.

Every field needs the same ingredients: a label, an input, a hint, an error message and the right ARIA attributes. Writing that by hand for every field is repetitive and easy to get wrong. A few small components fix it.

A TextField

App.jsx
JSX
function TextField({ label, error, hint, ...inputProps }) {
  const id = React.useId();           // unique id per instance
  const hintId = hint ? id + "-hint" : undefined;
  const errorId = error ? id + "-error" : undefined;

  return (
    <div style={{ marginBottom: 12 }}>
      <label htmlFor={id} style={{ display: "block", fontWeight: 600 }}>{label}</label>
      <input
        id={id}
        aria-label={label}
        aria-invalid={error ? "true" : undefined}
        aria-describedby={[hintId, errorId].filter(Boolean).join(" ") || undefined}
        {...inputProps}
      />
      {hint && <small id={hintId} style={{ display: "block", color: "#555" }}>{hint}</small>}
      {error && <small id={errorId} role="alert" style={{ display: "block", color: "#b71c1c" }}>{error}</small>}
    </div>
  );
}

function App() {
  const [name, setName] = React.useState("");
  return (
    <div>
      <TextField
        label="Full name"
        hint="As shown on your ID"
        value={name}
        onChange={(e) => setName(e.target.value)}
        error={name.length > 0 && name.length < 2 ? "Too short" : ""}
      />
      <p>Hello, {name}</p>
    </div>
  );
}

What the component does for you:

  • Generates a unique id with useId and links the label.
  • Connects the hint and the error through aria-describedby.
  • Passes every other prop (value, onChange, type, placeholder, maxLength…) straight to the <input> with ...inputProps, so it behaves like a normal input.

A SelectField

App.jsx
JSX
function SelectField({ label, options, ...selectProps }) {
  const id = React.useId();
  return (
    <div style={{ marginBottom: 12 }}>
      <label htmlFor={id} style={{ display: "block", fontWeight: 600 }}>{label}</label>
      <select id={id} aria-label={label} {...selectProps}>
        {options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
      </select>
    </div>
  );
}

function App() {
  const [level, setLevel] = React.useState("beginner");
  return (
    <div>
      <SelectField
        label="Level"
        value={level}
        onChange={(e) => setLevel(e.target.value)}
        options={[{ value: "beginner", label: "Beginner" }, { value: "advanced", label: "Advanced" }]}
      />
      <p>Chosen: {level}</p>
    </div>
  );
}

A tiny useForm hook

The state and handler pattern repeats in every form, so put it in a custom hook (see Custom Hooks):

App.jsx
JSX
function useForm(initialValues, validate) {
  const [values, setValues] = React.useState(initialValues);
  const [submitted, setSubmitted] = React.useState(false);

  const errors = validate(values);
  const isValid = Object.keys(errors).length === 0;

  function handleChange(e) {
    const { name, value } = e.target;
    setValues((v) => ({ ...v, [name]: value }));
  }

  function handleSubmit(onValid) {
    return (e) => {
      e.preventDefault();
      setSubmitted(true);
      if (isValid) onValid(values);
    };
  }

  function reset() {
    setValues(initialValues);
    setSubmitted(false);
  }

  return { values, errors: submitted ? errors : {}, handleChange, handleSubmit, reset };
}

function TextField({ label, error, ...props }) {
  const id = React.useId();
  return (
    <div>
      <label htmlFor={id}>{label}</label>{" "}
      <input id={id} aria-label={label} aria-invalid={error ? "true" : undefined} {...props} />
      {error && <small role="alert" style={{ color: "#b71c1c" }}> {error}</small>}
    </div>
  );
}

function App() {
  const [message, setMessage] = React.useState("");
  const form = useForm({ name: "", email: "" }, (v) => {
    const e = {};
    if (!v.name) e.name = "Required";
    if (!v.email.includes("@")) e.email = "Needs @";
    return e;
  });

  return (
    <form noValidate onSubmit={form.handleSubmit((v) => setMessage(`Joined: ${v.name} <${v.email}>`))}>
      <TextField label="Name" name="name" value={form.values.name} onChange={form.handleChange} error={form.errors.name} />
      <TextField label="Email" name="email" value={form.values.email} onChange={form.handleChange} error={form.errors.email} />
      <button type="submit">Join</button>
      <button type="button" onClick={form.reset}>Reset</button>
      <p role="status">{message}</p>
    </form>
  );
}

The App component now says what the form has, not how to track it.

Should you build or buy?

This small hook handles simple forms well. Large forms with many dependent rules benefit from a library such as React Hook Form or TanStack Form, and a schema validator such as Zod (with a resolver) so the same rules run on the client and on the server. Learn the basics here first; libraries make more sense once you know what they replace.

Design tips for form components

  • Pass through the rest of the props so every native attribute works.
  • Keep the label inside the component, so nobody forgets it.
  • Do not store values inside the field; let the form own them (controlled), unless you have a reason.
  • Give every component a clear job. A TextField should not know about your signup rules.

Key points

  • A field component bundles label, input, hint, error and ARIA wiring.
  • Forward the rest of the props to the input with ...inputProps.
  • A useForm hook removes repeated state and handler code; libraries help with large forms.